Skip to main content

secretenv_backend_azure/
lib.rs

1// Copyright (C) 2026 Mandeep Patel
2// SPDX-License-Identifier: AGPL-3.0-only
3
4//! Azure Key Vault backend for SecretEnv.
5//!
6//! Wraps the `az` CLI — **never** an Azure SDK. Every auth mode
7//! `az` supports (interactive `az login`, service principal,
8//! managed identity, federated credentials, Cloud Shell) works
9//! transparently because the CLI resolves auth the way the user
10//! already configured it.
11//!
12//! # URI shape
13//!
14//! `<instance>:///<secret-name>[#version=<id>]` — scheme is the
15//! instance name (e.g. `azure-prod`); path is the Key Vault secret
16//! name. The optional `#version=<id>` directive pins a specific
17//! version ID; `<id>` is a 32-character lowercase hex string (Azure
18//! generates these server-side) OR the literal `latest`. When absent
19//! or `latest`, the `--version` flag is omitted and `az` defaults to
20//! the newest enabled version.
21//!
22//! # Config fields
23//!
24//! - `azure_vault_url` (required) — fully-qualified Key Vault HTTPS
25//!   URL. Validated at factory time against a regex covering all four
26//!   sovereign clouds (Commercial, China, US Gov, Germany-legacy),
27//!   rejecting path traversal + hyphen-edge vault names.
28//! - `azure_tenant` (optional) — tenant ID or domain, passed via
29//!   `--tenant`.
30//! - `azure_subscription` (optional) — subscription ID, passed via
31//!   `--subscription`.
32//! - `az_bin` (test hook) — overrides the `az` binary path.
33//!
34//! # Safety
35//!
36//! Every CLI call goes through `Command::args([...])` with individual
37//! `&str`s — never `sh -c`, never `format!` into a shell string. The
38//! `set` path uses `--file /dev/stdin --encoding utf-8` — the secret
39//! value is piped through child stdin, NEVER on argv. The
40//! `--encoding utf-8` flag is REQUIRED when using `--file`; the
41//! default `base64` would interpret the stdin bytes as base64-encoded
42//! and corrupt the stored secret.
43//!
44//! See [[backends/azure]] in the kb for the full implementation spec.
45#![forbid(unsafe_code)]
46#![allow(clippy::module_name_repetitions)]
47
48use std::collections::HashMap;
49use std::io;
50use std::sync::OnceLock;
51
52use std::time::Duration;
53
54use anyhow::{anyhow, bail, Context, Result};
55use async_trait::async_trait;
56use regex::Regex;
57use secretenv_core::{
58    optional_duration_secs, optional_string, required_string, Backend, BackendFactory,
59    BackendStatus, BackendUri, Secret, DEFAULT_GET_TIMEOUT,
60};
61use serde::Deserialize;
62use tokio::process::Command;
63
64const CLI_NAME: &str = "az";
65const INSTALL_HINT: &str =
66    "brew install azure-cli  OR  https://learn.microsoft.com/cli/azure/install-azure-cli";
67
68/// Canonical vault-URL regex. Anchored; inner vault-name 3-24 chars
69/// alphanumeric + hyphen with no hyphen-edge; covers all four
70/// sovereign-cloud domains; lone trailing `/` accepted, anything
71/// after it rejected (path-traversal block).
72fn vault_url_re() -> &'static Regex {
73    static RE: OnceLock<Regex> = OnceLock::new();
74    // Statically-valid regex; `Regex::new` cannot fail here. If it
75    // ever does, initialization aborts at first backend construction
76    // — not an end-user-visible condition.
77    //
78    // Vault-name shape: 3-24 alphanumerics + hyphens, no hyphen-edge.
79    // Structure: [first alphanumeric][middle 1-22 alphanumeric|hyphen]
80    // [last alphanumeric] → total 3-24. Required middle+last (not
81    // optional) so 2-char names are rejected. Matches Azure's own
82    // naming rule.
83    #[allow(clippy::expect_used)]
84    RE.get_or_init(|| {
85        Regex::new(
86            r"^https://[a-zA-Z0-9][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]\.vault\.(azure\.net|azure\.cn|usgovcloudapi\.net|microsoftazure\.de)/?$",
87        )
88        .expect("vault URL regex is statically valid")
89    })
90}
91
92/// Azure Key Vault version IDs: 32 lowercase hex chars. Opaque;
93/// Azure generates these server-side.
94fn version_id_re() -> &'static Regex {
95    static RE: OnceLock<Regex> = OnceLock::new();
96    #[allow(clippy::expect_used)]
97    RE.get_or_init(|| Regex::new(r"^[0-9a-f]{32}$").expect("version ID regex is statically valid"))
98}
99
100/// Extract the short vault name from a validated vault URL.
101/// `https://my-kv-prod.vault.azure.net/` → `my-kv-prod`.
102fn vault_name_from_url(url: &str) -> &str {
103    // Safe: the factory validates URL shape before constructing the
104    // backend, so both slicing offsets are guaranteed present.
105    let after_scheme = url.trim_start_matches("https://");
106    after_scheme.split('.').next().unwrap_or(after_scheme)
107}
108
109/// A live instance of the Azure Key Vault backend.
110pub struct AzureBackend {
111    backend_type: &'static str,
112    instance_name: String,
113    #[allow(dead_code)] // Retained for diagnostics / future Level-3 probes.
114    azure_vault_url: String,
115    vault_name: String,
116    azure_tenant: Option<String>,
117    azure_subscription: Option<String>,
118    az_bin: String,
119    /// Per-instance fetch deadline; from `timeout_secs` config field.
120    timeout: Duration,
121}
122
123#[derive(Deserialize)]
124struct SecretShowResponse {
125    /// Secret value. `None` when the secret is a certificate binding
126    /// (`kid != null` in the response); surfaced as a distinct error.
127    ///
128    /// Deliberately NO `#[serde(default)]`: Azure's JSON response
129    /// always includes `value` (as a string or `null`). Omission is
130    /// unexpected — a missing key should surface as a deserialization
131    /// error rather than silently become `None`.
132    value: Option<String>,
133    /// Key-identifier present on certificate-bound secrets. If set,
134    /// we refuse to extract a scalar value — the caller asked for a
135    /// secret but the storage slot is bound to a cert. Azure omits
136    /// this field entirely for non-cert secrets, so `#[serde(default)]`
137    /// is load-bearing.
138    #[serde(default)]
139    kid: Option<String>,
140}
141
142#[derive(Deserialize)]
143struct AccountShowResponse {
144    #[serde(default, rename = "tenantId")]
145    tenant_id: String,
146    #[serde(default)]
147    name: String,
148    #[serde(default)]
149    user: Option<AccountUser>,
150}
151
152#[derive(Deserialize)]
153struct AccountUser {
154    #[serde(default)]
155    name: String,
156}
157
158impl AzureBackend {
159    fn cli_missing() -> BackendStatus {
160        BackendStatus::CliMissing {
161            cli_name: CLI_NAME.to_owned(),
162            install_hint: INSTALL_HINT.to_owned(),
163        }
164    }
165
166    fn operation_failure_message(&self, uri: &BackendUri, op: &str, stderr: &[u8]) -> String {
167        let stderr_str = String::from_utf8_lossy(stderr).trim().to_owned();
168        format!(
169            "azure backend '{}': {op} failed for URI '{}': {stderr_str}",
170            self.instance_name, uri.raw
171        )
172    }
173
174    /// Build an `az <group_path...> <extra_args...> --vault-name <v>
175    /// [--tenant <t>] [--subscription <s>] --output json` command.
176    /// `group_path` is the leading subcommand tokens (e.g. `["keyvault",
177    /// "secret", "show"]`); `extra_args` carries the per-op flags and
178    /// positionals between the group and the scoping tail. Keeping
179    /// the tail consistent across every op lets strict mocks lock
180    /// argv shape.
181    fn az_command(&self, group_path: &[&str], extra_args: &[&str]) -> Command {
182        let mut cmd = Command::new(&self.az_bin);
183        cmd.args(group_path);
184        cmd.args(extra_args);
185        cmd.args(["--vault-name", &self.vault_name]);
186        if let Some(t) = &self.azure_tenant {
187            cmd.args(["--tenant", t]);
188        }
189        if let Some(s) = &self.azure_subscription {
190            cmd.args(["--subscription", s]);
191        }
192        cmd.args(["--output", "json"]);
193        cmd
194    }
195
196    /// Strip exactly one leading `/` from `uri.path` to produce the
197    /// post-strip secret name. Azure KV names cannot begin with `/`;
198    /// triple-slash URIs (`azure-prod:///stripe-key`) yield
199    /// `uri.path = "/stripe-key"` which we strip to `stripe-key`.
200    fn secret_name(uri: &BackendUri) -> &str {
201        uri.path.strip_prefix('/').unwrap_or(&uri.path)
202    }
203
204    /// Resolve the `#version=<id>` directive. Returns `Some(id)` when
205    /// a specific version ID should be appended as `--version <id>`,
206    /// or `None` when the fragment is absent OR the directive value
207    /// is literally `latest` (both mean "omit `--version`").
208    fn resolve_version(&self, uri: &BackendUri) -> Result<Option<String>> {
209        let directives = uri.fragment_directives()?;
210        let Some(mut directives) = directives else {
211            return Ok(None);
212        };
213        if !directives.contains_key("version") {
214            let mut unsupported: Vec<&str> = directives.keys().map(String::as_str).collect();
215            unsupported.sort_unstable();
216            bail!(
217                "azure backend '{}': URI '{}' has unsupported fragment directive(s) [{}]; \
218                 azure recognizes only 'version' (example: \
219                 '#version=0123456789abcdef0123456789abcdef'). \
220                 See docs/fragment-vocabulary.md",
221                self.instance_name,
222                uri.raw,
223                unsupported.join(", ")
224            );
225        }
226        if directives.len() > 1 {
227            let mut extra: Vec<&str> =
228                directives.keys().filter(|k| k.as_str() != "version").map(String::as_str).collect();
229            extra.sort_unstable();
230            bail!(
231                "azure backend '{}': URI '{}' has unsupported directive(s) [{}] alongside \
232                 'version'; azure recognizes only 'version'. \
233                 See docs/fragment-vocabulary.md",
234                self.instance_name,
235                uri.raw,
236                extra.join(", ")
237            );
238        }
239        let Some(value) = directives.shift_remove("version") else {
240            unreachable!("version presence was checked above")
241        };
242        if value == "latest" {
243            return Ok(None);
244        }
245        if !version_id_re().is_match(&value) {
246            bail!(
247                "azure backend '{}': URI '{}' has invalid version value '{}'; expected \
248                 32-character lowercase hex (e.g. '0123456789abcdef0123456789abcdef') \
249                 or 'latest'",
250                self.instance_name,
251                uri.raw,
252                value
253            );
254        }
255        Ok(Some(value))
256    }
257
258    /// Fetch a secret value with no fragment dispatch. Used by `list`
259    /// (registry documents, which are always latest) and reused by
260    /// `get` after fragment resolution.
261    async fn get_raw(&self, uri: &BackendUri, version: Option<&str>) -> Result<String> {
262        let name = Self::secret_name(uri);
263        validate_secret_name(&self.instance_name, uri, name)?;
264        let mut extra: Vec<&str> = vec!["--name", name];
265        if let Some(v) = version {
266            extra.extend(["--version", v]);
267        }
268        let mut cmd = self.az_command(&["keyvault", "secret", "show"], &extra);
269        let output = cmd.output().await.with_context(|| {
270            format!(
271                "azure backend '{}': failed to invoke 'az keyvault secret show' \
272                 for URI '{}'",
273                self.instance_name, uri.raw
274            )
275        })?;
276        if !output.status.success() {
277            bail!(self.operation_failure_message(uri, "get", &output.stderr));
278        }
279        let parsed: SecretShowResponse =
280            serde_json::from_slice(&output.stdout).with_context(|| {
281                format!(
282                    "azure backend '{}': failed to parse JSON response from 'az keyvault \
283                 secret show' for URI '{}'",
284                    self.instance_name, uri.raw
285                )
286            })?;
287        if let Some(kid) = parsed.kid {
288            bail!(
289                "azure backend '{}': URI '{}' resolves to a certificate-bound secret \
290                 (kid='{}'); v0.3 supports text secrets only",
291                self.instance_name,
292                uri.raw,
293                kid
294            );
295        }
296        let value = parsed.value.ok_or_else(|| {
297            anyhow!(
298                "azure backend '{}': URI '{}' response missing 'value' field",
299                self.instance_name,
300                uri.raw
301            )
302        })?;
303        Ok(value.strip_suffix('\n').unwrap_or(&value).to_owned())
304    }
305}
306
307/// Validate that `name` matches Azure Key Vault's secret name charset
308/// `[a-zA-Z0-9-]{1,127}`. Performed BEFORE any `az` invocation so
309/// copy-paste mistakes fail locally instead of burning an Azure AD
310/// token acquisition + subprocess.
311fn validate_secret_name(instance_name: &str, uri: &BackendUri, name: &str) -> Result<()> {
312    if name.is_empty() || name.len() > 127 {
313        bail!(
314            "azure backend '{instance_name}': URI '{}' has invalid secret name \
315             (length {}); must be 1..=127 chars",
316            uri.raw,
317            name.len()
318        );
319    }
320    if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
321        bail!(
322            "azure backend '{instance_name}': URI '{}' has invalid secret name '{}'; \
323             Azure Key Vault names allow only [a-zA-Z0-9-]",
324            uri.raw,
325            name
326        );
327    }
328    Ok(())
329}
330
331#[async_trait]
332impl Backend for AzureBackend {
333    fn backend_type(&self) -> &str {
334        self.backend_type
335    }
336
337    fn instance_name(&self) -> &str {
338        &self.instance_name
339    }
340
341    fn timeout(&self) -> Duration {
342        self.timeout
343    }
344
345    #[allow(clippy::similar_names)]
346    async fn check(&self) -> BackendStatus {
347        // Level 1 (`az --version`) + Level 2 (`az account show`) run
348        // concurrently via `tokio::join!`. The two probes are
349        // independent; `doctor` latency ~halved per backend.
350        let version_fut = Command::new(&self.az_bin).arg("--version").output();
351
352        let mut account_cmd = Command::new(&self.az_bin);
353        account_cmd.args(["account", "show", "--output", "json"]);
354        if let Some(s) = &self.azure_subscription {
355            account_cmd.args(["--subscription", s]);
356        }
357        let account_fut = account_cmd.output();
358
359        let (version_res, account_res) = tokio::join!(version_fut, account_fut);
360
361        // --- Level 1 ---
362        let version_out = match version_res {
363            Ok(o) => o,
364            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::cli_missing(),
365            Err(e) => {
366                return BackendStatus::Error {
367                    message: format!(
368                        "azure backend '{}': failed to invoke '{}': {e}",
369                        self.instance_name, self.az_bin
370                    ),
371                };
372            }
373        };
374        if !version_out.status.success() {
375            return BackendStatus::Error {
376                message: format!(
377                    "azure backend '{}': 'az --version' exited non-zero: {}",
378                    self.instance_name,
379                    String::from_utf8_lossy(&version_out.stderr).trim()
380                ),
381            };
382        }
383        // `az --version` output: first line "azure-cli  <x.y.z>\n..."
384        // (variable whitespace). Extract the first token that looks
385        // like a version after "azure-cli".
386        let cli_version = {
387            let stdout = String::from_utf8_lossy(&version_out.stdout);
388            stdout
389                .lines()
390                .next()
391                .and_then(|line| line.trim().strip_prefix("azure-cli"))
392                .map_or_else(|| "unknown".to_owned(), |rest| format!("azure-cli {}", rest.trim()))
393        };
394
395        // --- Level 2 ---
396        let account_out = match account_res {
397            Ok(o) => o,
398            Err(e) => {
399                return BackendStatus::Error {
400                    message: format!(
401                        "azure backend '{}': failed to invoke 'az account show': {e}",
402                        self.instance_name
403                    ),
404                };
405            }
406        };
407        if !account_out.status.success() {
408            let stderr = String::from_utf8_lossy(&account_out.stderr).trim().to_owned();
409            return BackendStatus::NotAuthenticated {
410                hint: format!(
411                    "run: az login  OR  az login --service-principal --tenant <t> \
412                     --username <client-id> --password <secret> (stderr: {stderr})"
413                ),
414            };
415        }
416        let parsed: AccountShowResponse = match serde_json::from_slice(&account_out.stdout) {
417            Ok(p) => p,
418            Err(e) => {
419                return BackendStatus::Error {
420                    message: format!(
421                        "azure backend '{}': parsing 'az account show' JSON: {e}",
422                        self.instance_name
423                    ),
424                };
425            }
426        };
427        let user = parsed.user.map_or_else(String::new, |u| u.name);
428
429        BackendStatus::Ok {
430            cli_version,
431            identity: format!(
432                "user={user} tenant={} subscription={} vault={}",
433                parsed.tenant_id, parsed.name, self.vault_name
434            ),
435        }
436    }
437
438    async fn get(&self, uri: &BackendUri) -> Result<Secret<String>> {
439        // Fragment + secret-name validation happen BEFORE any `az`
440        // call (v0.2.6 pattern). Invalid URIs fail locally without
441        // burning an Azure AD token, a network round-trip, or an
442        // audit-log entry for a failed read.
443        let version = self.resolve_version(uri)?;
444        self.get_raw(uri, version.as_deref()).await.map(Secret::new)
445    }
446
447    async fn set(&self, uri: &BackendUri, value: &str) -> Result<()> {
448        // A fragment (`#version=<id>`) on a `set` URI is nonsensical
449        // — Azure assigns the version ID server-side; you can't ask
450        // to write to a specific-numbered version. Reject before
451        // shelling out.
452        uri.reject_any_fragment("azure")?;
453        let name = Self::secret_name(uri);
454        validate_secret_name(&self.instance_name, uri, name)?;
455
456        // Secret value is piped via child stdin — NEVER on argv. The
457        // `--file /dev/stdin` + `--encoding utf-8` pair is the only
458        // CV-1-compliant write path for `az keyvault secret set`.
459        // `--encoding utf-8` is LOAD-BEARING: the default `base64`
460        // would interpret stdin bytes as b64-encoded and corrupt the
461        // stored secret.
462        let mut cmd = self.az_command(
463            &["keyvault", "secret", "set"],
464            &["--name", name, "--file", "/dev/stdin", "--encoding", "utf-8"],
465        );
466        cmd.stdin(std::process::Stdio::piped());
467        cmd.stdout(std::process::Stdio::piped());
468        cmd.stderr(std::process::Stdio::piped());
469        let mut child = cmd.spawn().with_context(|| {
470            format!(
471                "azure backend '{}': failed to spawn 'az keyvault secret set' for \
472                 URI '{}'",
473                self.instance_name, uri.raw
474            )
475        })?;
476        if let Some(mut stdin) = child.stdin.take() {
477            use tokio::io::AsyncWriteExt;
478            match stdin.write_all(value.as_bytes()).await {
479                Ok(()) => {}
480                Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
481                Err(e) => {
482                    return Err(anyhow::Error::new(e).context(format!(
483                        "azure backend '{}': failed to write secret value to az stdin",
484                        self.instance_name
485                    )));
486                }
487            }
488            stdin.shutdown().await.ok();
489            drop(stdin);
490        }
491        let output = child.wait_with_output().await.with_context(|| {
492            format!(
493                "azure backend '{}': 'az keyvault secret set' exited abnormally for \
494                 URI '{}'",
495                self.instance_name, uri.raw
496            )
497        })?;
498        if !output.status.success() {
499            bail!(self.operation_failure_message(uri, "set", &output.stderr));
500        }
501        Ok(())
502    }
503
504    /// v0.15 migrate destination path. `Native` per the v0.15 audit
505    /// table — wraps `set()` taking the value by `&Secret<String>`
506    /// reference (SEC-INV-10 borrow-not-clone; `expose_secret`
507    /// returns a `&str` borrow with the same lifetime as `value`,
508    /// no allocation).
509    async fn write_secret(&self, uri: &BackendUri, value: &Secret<String>) -> Result<()> {
510        self.set(uri, value.expose_secret()).await
511    }
512
513    /// v0.15 migrate `--delete-source` cleanup path. `Native` per
514    /// the v0.15 audit table — passthrough to `delete()`. Not called
515    /// unless the operator opts in via `--delete-source`.
516    async fn delete_secret(&self, uri: &BackendUri) -> Result<()> {
517        self.delete(uri).await
518    }
519
520    /// v0.15 migrate success-message cleanup hint — copy-paste form
521    /// of `az keyvault secret delete`. Soft-delete only; the operator
522    /// can `purge-deleted-secret` separately if their role allows.
523    fn delete_hint(&self, uri: &BackendUri) -> String {
524        let name = Self::secret_name(uri);
525        format!(
526            "az keyvault secret delete --vault-name {vault} --name {name}",
527            vault = self.vault_name,
528        )
529    }
530
531    async fn delete(&self, uri: &BackendUri) -> Result<()> {
532        uri.reject_any_fragment("azure")?;
533        let name = Self::secret_name(uri);
534        validate_secret_name(&self.instance_name, uri, name)?;
535        // Azure Key Vault has soft-delete enabled by default —
536        // `secret delete` marks the secret as deleted but preserves
537        // it for the recovery window (90 days default). `purge` is
538        // a separate permission most users don't have; we do NOT
539        // chain it. This is asymmetric with aws-secrets
540        // (--force-delete-without-recovery) and with gcp (full
541        // delete) — platform reality, documented in docs/.
542        let mut cmd = self.az_command(&["keyvault", "secret", "delete"], &["--name", name]);
543        let output = cmd.output().await.with_context(|| {
544            format!(
545                "azure backend '{}': failed to invoke 'az keyvault secret delete' \
546                 for URI '{}'",
547                self.instance_name, uri.raw
548            )
549        })?;
550        if !output.status.success() {
551            bail!(self.operation_failure_message(uri, "delete", &output.stderr));
552        }
553        Ok(())
554    }
555
556    async fn list(&self, uri: &BackendUri) -> Result<Vec<(String, String)>> {
557        // Registry documents are stored as a single Azure secret
558        // whose value is a JSON alias→URI map — same shape as
559        // aws-ssm / aws-secrets / vault / gcp.
560        let body = self.get_raw(uri, None).await?;
561        let map: HashMap<String, String> = serde_json::from_str(&body).with_context(|| {
562            format!(
563                "azure backend '{}': secret body at '{}' is not a JSON alias→URI map",
564                self.instance_name, uri.raw
565            )
566        })?;
567        Ok(map.into_iter().collect())
568    }
569}
570
571/// Factory for the Azure Key Vault backend.
572pub struct AzureFactory(&'static str);
573
574impl AzureFactory {
575    /// Construct the factory. Equivalent to `AzureFactory::default()`.
576    #[must_use]
577    pub const fn new() -> Self {
578        Self("azure")
579    }
580}
581
582impl Default for AzureFactory {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588impl BackendFactory for AzureFactory {
589    fn backend_type(&self) -> &str {
590        self.0
591    }
592
593    fn create(
594        &self,
595        instance_name: &str,
596        config: &HashMap<String, toml::Value>,
597    ) -> Result<Box<dyn Backend>> {
598        let azure_vault_url = required_string(config, "azure_vault_url", "azure", instance_name)?;
599        if !vault_url_re().is_match(&azure_vault_url) {
600            bail!(
601                "azure instance '{instance_name}': field 'azure_vault_url' value \
602                 '{azure_vault_url}' is not a valid Azure Key Vault URL (expected \
603                 '<https://<name>.vault.{{azure.net|azure.cn|usgovcloudapi.net|\
604                 microsoftazure.de}}/>' — no path, no hyphen-edge name)"
605            );
606        }
607        let vault_name = vault_name_from_url(&azure_vault_url).to_owned();
608        let azure_tenant = optional_string(config, "azure_tenant", "azure", instance_name)?;
609        let azure_subscription =
610            optional_string(config, "azure_subscription", "azure", instance_name)?;
611        let az_bin = optional_string(config, "az_bin", "azure", instance_name)?
612            .unwrap_or_else(|| CLI_NAME.to_owned());
613        let timeout = optional_duration_secs(config, "timeout_secs", "azure", instance_name)?
614            .unwrap_or(DEFAULT_GET_TIMEOUT);
615        Ok(Box::new(AzureBackend {
616            backend_type: "azure",
617            instance_name: instance_name.to_owned(),
618            azure_vault_url,
619            vault_name,
620            azure_tenant,
621            azure_subscription,
622            az_bin,
623            timeout,
624        }))
625    }
626}
627
628#[cfg(test)]
629#[allow(clippy::unwrap_used, clippy::expect_used)]
630mod tests {
631    use std::path::Path;
632
633    use secretenv_testing::{Response, StrictMock};
634    use tempfile::TempDir;
635
636    use super::*;
637
638    const VAULT_URL: &str = "https://my-kv-prod.vault.azure.net/";
639    const VAULT_NAME: &str = "my-kv-prod";
640    const TENANT: &str = "contoso.onmicrosoft.com";
641    const SUB: &str = "00000000-0000-0000-0000-000000000000";
642    const VERSION_HEX: &str = "0123456789abcdef0123456789abcdef";
643
644    fn backend(mock_path: &Path, tenant: Option<&str>, sub: Option<&str>) -> AzureBackend {
645        AzureBackend {
646            backend_type: "azure",
647            instance_name: "azure-prod".to_owned(),
648            azure_vault_url: VAULT_URL.to_owned(),
649            vault_name: VAULT_NAME.to_owned(),
650            azure_tenant: tenant.map(ToOwned::to_owned),
651            azure_subscription: sub.map(ToOwned::to_owned),
652            az_bin: mock_path.to_str().unwrap().to_owned(),
653            timeout: DEFAULT_GET_TIMEOUT,
654        }
655    }
656
657    fn backend_with_nonexistent_az() -> AzureBackend {
658        AzureBackend {
659            backend_type: "azure",
660            instance_name: "azure-prod".to_owned(),
661            azure_vault_url: VAULT_URL.to_owned(),
662            vault_name: VAULT_NAME.to_owned(),
663            azure_tenant: None,
664            azure_subscription: None,
665            az_bin: "/definitely/not/a/real/path/to/az-binary-XYZ".to_owned(),
666            timeout: DEFAULT_GET_TIMEOUT,
667        }
668    }
669
670    /// `keyvault secret show --name <n> --vault-name <v> --output json`.
671    /// Shared scoping tail (`--vault-name ... --output json`) lives on
672    /// every argv so strict mocks implicitly lock `--vault-name`
673    /// presence — a regression dropping it diverges from the declared
674    /// shape and produces exit 97.
675    fn show_argv(name: &str) -> [&str; 9] {
676        [
677            "keyvault",
678            "secret",
679            "show",
680            "--name",
681            name,
682            "--vault-name",
683            VAULT_NAME,
684            "--output",
685            "json",
686        ]
687    }
688
689    fn set_argv(name: &str) -> [&str; 13] {
690        [
691            "keyvault",
692            "secret",
693            "set",
694            "--name",
695            name,
696            "--file",
697            "/dev/stdin",
698            "--encoding",
699            "utf-8",
700            "--vault-name",
701            VAULT_NAME,
702            "--output",
703            "json",
704        ]
705    }
706
707    fn delete_argv(name: &str) -> [&str; 9] {
708        [
709            "keyvault",
710            "secret",
711            "delete",
712            "--name",
713            name,
714            "--vault-name",
715            VAULT_NAME,
716            "--output",
717            "json",
718        ]
719    }
720
721    const VERSION_ARGV: &[&str] = &["--version"];
722    const ACCOUNT_SHOW_ARGV: &[&str] = &["account", "show", "--output", "json"];
723
724    const ACCOUNT_OK_JSON: &str = "{\"id\":\"11111111-1111-1111-1111-111111111111\",\"name\":\"Contoso Prod\",\"tenantId\":\"22222222-2222-2222-2222-222222222222\",\"user\":{\"name\":\"alice@contoso.com\",\"type\":\"user\"}}\n";
725
726    fn check_mock_ok(_dir: &Path) -> StrictMock {
727        StrictMock::new("az")
728            .on(
729                VERSION_ARGV,
730                Response::success(
731                    "azure-cli                         2.60.0\n\ncore                              2.60.0\n",
732                ),
733            )
734            .on(ACCOUNT_SHOW_ARGV, Response::success(ACCOUNT_OK_JSON))
735    }
736
737    // ---- Factory ----
738
739    #[test]
740    fn factory_backend_type_is_azure() {
741        assert_eq!(AzureFactory::new().backend_type(), "azure");
742    }
743
744    #[test]
745    fn factory_errors_when_vault_url_missing() {
746        let factory = AzureFactory::new();
747        let cfg: HashMap<String, toml::Value> = HashMap::new();
748        let Err(err) = factory.create("azure-prod", &cfg) else {
749            panic!("expected error when azure_vault_url is missing");
750        };
751        let msg = format!("{err:#}");
752        assert!(msg.contains("azure_vault_url"), "names missing field: {msg}");
753        assert!(msg.contains("azure-prod"), "names instance: {msg}");
754    }
755
756    #[test]
757    fn factory_accepts_canonical_url() {
758        let factory = AzureFactory::new();
759        let mut cfg: HashMap<String, toml::Value> = HashMap::new();
760        cfg.insert("azure_vault_url".to_owned(), toml::Value::String(VAULT_URL.to_owned()));
761        let b = factory.create("azure-prod", &cfg).unwrap();
762        assert_eq!(b.backend_type(), "azure");
763        assert_eq!(b.instance_name(), "azure-prod");
764    }
765
766    #[test]
767    fn factory_accepts_sovereign_cloud_urls() {
768        // All four canonical sovereign-cloud domains must pass the regex.
769        for url in [
770            "https://my-kv.vault.azure.net/",
771            "https://my-kv.vault.azure.cn/",
772            "https://my-kv.vault.usgovcloudapi.net/",
773            "https://my-kv.vault.microsoftazure.de/",
774        ] {
775            let factory = AzureFactory::new();
776            let mut cfg: HashMap<String, toml::Value> = HashMap::new();
777            cfg.insert("azure_vault_url".to_owned(), toml::Value::String(url.to_owned()));
778            let r = factory.create("azure-prod", &cfg);
779            assert!(
780                r.is_ok(),
781                "expected {url} to pass factory validation: {}",
782                r.err().map_or_else(String::new, |e| format!("{e:#}"))
783            );
784        }
785    }
786
787    #[test]
788    fn factory_rejects_one_char_vault_name() {
789        // Azure vault naming rule is 3-24 chars. A 1-char leading
790        // group matches the outer `[a-zA-Z0-9]` but then fails the
791        // required middle+last inner pair. Lock the boundary.
792        let factory = AzureFactory::new();
793        let mut cfg: HashMap<String, toml::Value> = HashMap::new();
794        cfg.insert(
795            "azure_vault_url".to_owned(),
796            toml::Value::String("https://a.vault.azure.net/".to_owned()),
797        );
798        let Err(err) = factory.create("azure-prod", &cfg) else {
799            panic!("expected rejection for 1-char vault name");
800        };
801        assert!(format!("{err:#}").contains("not a valid"));
802    }
803
804    #[test]
805    fn factory_rejects_two_char_vault_name() {
806        // 2-char also below the 3-char minimum.
807        let factory = AzureFactory::new();
808        let mut cfg: HashMap<String, toml::Value> = HashMap::new();
809        cfg.insert(
810            "azure_vault_url".to_owned(),
811            toml::Value::String("https://ab.vault.azure.net/".to_owned()),
812        );
813        let Err(err) = factory.create("azure-prod", &cfg) else {
814            panic!("expected rejection for 2-char vault name");
815        };
816        assert!(format!("{err:#}").contains("not a valid"));
817    }
818
819    #[test]
820    fn factory_accepts_three_char_vault_name() {
821        // Minimum valid vault-name length.
822        let factory = AzureFactory::new();
823        let mut cfg: HashMap<String, toml::Value> = HashMap::new();
824        cfg.insert(
825            "azure_vault_url".to_owned(),
826            toml::Value::String("https://abc.vault.azure.net/".to_owned()),
827        );
828        factory.create("azure-prod", &cfg).expect("3-char vault name must be accepted");
829    }
830
831    #[test]
832    fn factory_rejects_hyphen_edge_vault_names() {
833        // Azure's own naming rules disallow leading/trailing hyphens.
834        for bad in ["https://-foo.vault.azure.net/", "https://foo-.vault.azure.net/"] {
835            let factory = AzureFactory::new();
836            let mut cfg: HashMap<String, toml::Value> = HashMap::new();
837            cfg.insert("azure_vault_url".to_owned(), toml::Value::String(bad.to_owned()));
838            let Err(err) = factory.create("azure-prod", &cfg) else {
839                panic!("expected rejection for {bad}");
840            };
841            assert!(format!("{err:#}").contains("not a valid"), "rejection for {bad}");
842        }
843    }
844
845    #[test]
846    fn factory_rejects_path_traversal_in_vault_url() {
847        // Anchored regex must reject anything past the lone optional
848        // trailing `/`. `url::Url::parse()` would accept this; regex
849        // discipline is the gate.
850        for bad in [
851            "https://my-kv.vault.azure.net/evil/../etc",
852            "https://my-kv.vault.azure.net/secrets/../x",
853            "http://my-kv.vault.azure.net/", // must be https
854            "https://my-kv.evil.azure.net/", // must be .vault.
855        ] {
856            let factory = AzureFactory::new();
857            let mut cfg: HashMap<String, toml::Value> = HashMap::new();
858            cfg.insert("azure_vault_url".to_owned(), toml::Value::String(bad.to_owned()));
859            let Err(err) = factory.create("azure-prod", &cfg) else {
860                panic!("expected rejection for {bad}");
861            };
862            assert!(format!("{err:#}").contains("not a valid"), "rejection for {bad}");
863        }
864    }
865
866    // ---- check ----
867
868    #[tokio::test]
869    async fn check_cli_missing_on_enoent() {
870        let b = backend_with_nonexistent_az();
871        match b.check().await {
872            BackendStatus::CliMissing { cli_name, install_hint } => {
873                assert_eq!(cli_name, "az");
874                assert!(install_hint.contains("azure-cli"));
875            }
876            other => panic!("expected CliMissing, got {other:?}"),
877        }
878    }
879
880    #[tokio::test]
881    async fn check_level1_parses_multiline_version() {
882        let dir = TempDir::new().unwrap();
883        let mock = check_mock_ok(dir.path()).install(dir.path());
884        let b = backend(&mock, None, None);
885        match b.check().await {
886            BackendStatus::Ok { cli_version, .. } => {
887                assert!(cli_version.contains("azure-cli"), "got: {cli_version}");
888                assert!(cli_version.contains("2.60.0"), "parses version: {cli_version}");
889            }
890            other => panic!("expected Ok, got {other:?}"),
891        }
892    }
893
894    #[tokio::test]
895    async fn check_level2_auth_ok() {
896        let dir = TempDir::new().unwrap();
897        let mock = check_mock_ok(dir.path()).install(dir.path());
898        let b = backend(&mock, None, None);
899        match b.check().await {
900            BackendStatus::Ok { identity, .. } => {
901                assert!(identity.contains("user=alice@contoso.com"), "identity: {identity}");
902                assert!(identity.contains("tenant=22222222"), "identity: {identity}");
903                assert!(identity.contains("subscription=Contoso Prod"), "identity: {identity}");
904                assert!(identity.contains("vault=my-kv-prod"), "identity: {identity}");
905            }
906            other => panic!("expected Ok, got {other:?}"),
907        }
908    }
909
910    #[tokio::test]
911    async fn check_level2_not_authenticated() {
912        let dir = TempDir::new().unwrap();
913        let mock = StrictMock::new("az")
914            .on(VERSION_ARGV, Response::success("azure-cli  2.60.0\n"))
915            .on(
916                ACCOUNT_SHOW_ARGV,
917                Response::failure(1, "ERROR: Please run 'az login' to setup account.\n"),
918            )
919            .install(dir.path());
920        let b = backend(&mock, None, None);
921        match b.check().await {
922            BackendStatus::NotAuthenticated { hint } => {
923                assert!(hint.contains("az login"), "hint: {hint}");
924                assert!(hint.contains("service-principal"), "hint: {hint}");
925            }
926            other => panic!("expected NotAuthenticated, got {other:?}"),
927        }
928    }
929
930    // ---- get ----
931
932    #[tokio::test]
933    async fn get_returns_value_from_json_response() {
934        let dir = TempDir::new().unwrap();
935        let mock = StrictMock::new("az")
936            .on(
937                &show_argv("stripe-key"),
938                Response::success("{\"id\":\"https://my-kv-prod.vault.azure.net/secrets/stripe-key/abc\",\"value\":\"sk_live_abc\",\"attributes\":{\"enabled\":true}}\n"),
939            )
940            .install(dir.path());
941        let b = backend(&mock, None, None);
942        let uri = BackendUri::parse("azure-prod:///stripe-key").unwrap();
943        assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "sk_live_abc");
944    }
945
946    #[tokio::test]
947    async fn get_at_specific_version() {
948        // `--version <id>` lives inside `extra_args` (between the
949        // group tokens and the scoping tail), so it lands BEFORE
950        // `--vault-name` + `--output json`. Mirror the real argv
951        // layout here.
952        let dir = TempDir::new().unwrap();
953        let argv: Vec<&str> = [
954            "keyvault",
955            "secret",
956            "show",
957            "--name",
958            "stripe-key",
959            "--version",
960            VERSION_HEX,
961            "--vault-name",
962            VAULT_NAME,
963            "--output",
964            "json",
965        ]
966        .to_vec();
967        let mock = StrictMock::new("az")
968            .on(&argv, Response::success("{\"value\":\"older-value\",\"attributes\":{}}\n"))
969            .install(dir.path());
970        let b = backend(&mock, None, None);
971        let uri =
972            BackendUri::parse(&format!("azure-prod:///stripe-key#version={VERSION_HEX}")).unwrap();
973        assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "older-value");
974    }
975
976    #[tokio::test]
977    async fn get_latest_literal_omits_version_flag() {
978        // `#version=latest` must normalize to NO --version flag. If
979        // the backend emitted `--version latest`, this declared argv
980        // (without --version) would NOT match → exit 97.
981        let dir = TempDir::new().unwrap();
982        let mock = StrictMock::new("az")
983            .on(&show_argv("stripe-key"), Response::success("{\"value\":\"v\"}\n"))
984            .install(dir.path());
985        let b = backend(&mock, None, None);
986        let uri = BackendUri::parse("azure-prod:///stripe-key#version=latest").unwrap();
987        assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "v");
988    }
989
990    #[tokio::test]
991    async fn get_strips_single_trailing_newline() {
992        let dir = TempDir::new().unwrap();
993        let mock = StrictMock::new("az")
994            .on(&show_argv("multi-line"), Response::success("{\"value\":\"line1\\nline2\\n\"}\n"))
995            .install(dir.path());
996        let b = backend(&mock, None, None);
997        let uri = BackendUri::parse("azure-prod:///multi-line").unwrap();
998        assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "line1\nline2");
999    }
1000
1001    #[tokio::test]
1002    async fn get_empty_value() {
1003        let dir = TempDir::new().unwrap();
1004        let mock = StrictMock::new("az")
1005            .on(&show_argv("empty"), Response::success("{\"value\":\"\"}\n"))
1006            .install(dir.path());
1007        let b = backend(&mock, None, None);
1008        let uri = BackendUri::parse("azure-prod:///empty").unwrap();
1009        assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "");
1010    }
1011
1012    #[tokio::test]
1013    async fn get_secret_not_found_wraps_stderr() {
1014        let dir = TempDir::new().unwrap();
1015        let mock = StrictMock::new("az")
1016            .on(
1017                &show_argv("missing"),
1018                Response::failure(
1019                    1,
1020                    "ERROR: (SecretNotFound) A secret with (name/id) missing was not found in this key vault\n",
1021                ),
1022            )
1023            .install(dir.path());
1024        let b = backend(&mock, None, None);
1025        let uri = BackendUri::parse("azure-prod:///missing").unwrap();
1026        let err = b.get(&uri).await.unwrap_err();
1027        let msg = format!("{err:#}");
1028        assert!(msg.contains("azure-prod"), "names instance: {msg}");
1029        assert!(msg.contains("SecretNotFound"), "passes through: {msg}");
1030    }
1031
1032    #[tokio::test]
1033    async fn get_forbidden_wraps_stderr() {
1034        let dir = TempDir::new().unwrap();
1035        let mock = StrictMock::new("az")
1036            .on(
1037                &show_argv("locked"),
1038                Response::failure(
1039                    1,
1040                    "ERROR: (Forbidden) The user, group or application does not have secrets get permission\n",
1041                ),
1042            )
1043            .install(dir.path());
1044        let b = backend(&mock, None, None);
1045        let uri = BackendUri::parse("azure-prod:///locked").unwrap();
1046        assert!(format!("{:#}", b.get(&uri).await.unwrap_err()).contains("Forbidden"));
1047    }
1048
1049    #[tokio::test]
1050    async fn get_rejects_shorthand_fragment() {
1051        // Empty-rule mock: any `az` invocation produces exit 97. The
1052        // error MUST come from the fragment parser BEFORE any `az` call.
1053        let dir = TempDir::new().unwrap();
1054        let mock = StrictMock::new("az").install(dir.path());
1055        let b = backend(&mock, None, None);
1056        let uri = BackendUri::parse("azure-prod:///stripe-key#password").unwrap();
1057        let err = b.get(&uri).await.unwrap_err();
1058        let msg = format!("{err:#}");
1059        assert!(msg.contains("shorthand"), "error names problem: {msg}");
1060        assert!(
1061            !msg.contains("strict-mock-no-match"),
1062            "error from fragment parser, not mock: {msg}"
1063        );
1064    }
1065
1066    #[tokio::test]
1067    async fn get_rejects_unsupported_directive() {
1068        let dir = TempDir::new().unwrap();
1069        let mock = StrictMock::new("az").install(dir.path());
1070        let b = backend(&mock, None, None);
1071        let uri = BackendUri::parse("azure-prod:///stripe-key#json-key=password").unwrap();
1072        let err = b.get(&uri).await.unwrap_err();
1073        let msg = format!("{err:#}");
1074        assert!(msg.contains("unsupported"), "names problem: {msg}");
1075        assert!(msg.contains("json-key"), "lists offender: {msg}");
1076        assert!(msg.contains("version"), "names supported directive: {msg}");
1077        assert!(msg.contains("fragment-vocabulary"), "error links to canonical doc: {msg}");
1078        assert!(!msg.contains("strict-mock-no-match"), "error from backend, not mock: {msg}");
1079    }
1080
1081    #[tokio::test]
1082    async fn get_rejects_invalid_version_format() {
1083        let dir = TempDir::new().unwrap();
1084        let mock = StrictMock::new("az").install(dir.path());
1085        let b = backend(&mock, None, None);
1086        let uri = BackendUri::parse("azure-prod:///stripe-key#version=not-hex").unwrap();
1087        let err = b.get(&uri).await.unwrap_err();
1088        let msg = format!("{err:#}");
1089        assert!(msg.contains("invalid version value"), "names problem: {msg}");
1090        assert!(msg.contains("'not-hex'"), "quotes offender: {msg}");
1091        assert!(msg.contains("32-character"), "names expected shape: {msg}");
1092        assert!(!msg.contains("strict-mock-no-match"), "error from backend, not mock: {msg}");
1093    }
1094
1095    #[tokio::test]
1096    async fn get_rejects_invalid_secret_name() {
1097        let dir = TempDir::new().unwrap();
1098        let mock = StrictMock::new("az").install(dir.path());
1099        let b = backend(&mock, None, None);
1100        // Underscore is not in [a-zA-Z0-9-].
1101        let uri = BackendUri::parse("azure-prod:///bad_name").unwrap();
1102        let err = b.get(&uri).await.unwrap_err();
1103        let msg = format!("{err:#}");
1104        assert!(msg.contains("invalid secret name"), "names problem: {msg}");
1105        assert!(!msg.contains("strict-mock-no-match"), "error from backend, not mock: {msg}");
1106    }
1107
1108    #[tokio::test]
1109    async fn get_rejects_certificate_bound_secret() {
1110        // `kid` field present → secret is bound to a cert; v0.3 only
1111        // supports plain text secrets.
1112        let dir = TempDir::new().unwrap();
1113        let mock = StrictMock::new("az")
1114            .on(
1115                &show_argv("cert-bound"),
1116                Response::success(
1117                    "{\"value\":null,\"kid\":\"https://my-kv-prod.vault.azure.net/keys/x/abc\"}\n",
1118                ),
1119            )
1120            .install(dir.path());
1121        let b = backend(&mock, None, None);
1122        let uri = BackendUri::parse("azure-prod:///cert-bound").unwrap();
1123        let err = b.get(&uri).await.unwrap_err();
1124        let msg = format!("{err:#}");
1125        assert!(msg.contains("certificate-bound"), "names problem: {msg}");
1126        assert!(msg.contains("kid="), "shows kid value: {msg}");
1127    }
1128
1129    // ---- set ----
1130
1131    #[tokio::test]
1132    async fn set_succeeds_with_encoding_utf8() {
1133        let dir = TempDir::new().unwrap();
1134        let mock = StrictMock::new("az")
1135            .on(
1136                &set_argv("rotate-me"),
1137                Response::success_with_stdin(
1138                    "{\"value\":\"new-val\",\"id\":\"https://...\"}\n",
1139                    vec!["new-val".to_owned()],
1140                ),
1141            )
1142            .install(dir.path());
1143        let b = backend(&mock, None, None);
1144        let uri = BackendUri::parse("azure-prod:///rotate-me").unwrap();
1145        b.set(&uri, "new-val").await.unwrap();
1146    }
1147
1148    #[tokio::test]
1149    async fn set_passes_secret_value_via_stdin_not_argv() {
1150        // CV-1 discipline: argv carries `--file /dev/stdin` sentinel
1151        // (NOT the secret), stdin-fragment check requires the secret
1152        // in stdin. Strict match on both implies "secret on stdin,
1153        // NOT on argv".
1154        let very_sensitive = "sk_live_TOP_SECRET_azure_never_argv_XYZ";
1155        let dir = TempDir::new().unwrap();
1156        let mock = StrictMock::new("az")
1157            .on(
1158                &set_argv("stripe-key"),
1159                Response::success_with_stdin("{}\n", vec![very_sensitive.to_owned()]),
1160            )
1161            .install(dir.path());
1162        let b = backend(&mock, None, None);
1163        let uri = BackendUri::parse("azure-prod:///stripe-key").unwrap();
1164        b.set(&uri, very_sensitive).await.unwrap();
1165    }
1166
1167    #[tokio::test]
1168    async fn set_rejects_fragment_on_uri() {
1169        let dir = TempDir::new().unwrap();
1170        let mock = StrictMock::new("az").install(dir.path());
1171        let b = backend(&mock, None, None);
1172        let uri =
1173            BackendUri::parse(&format!("azure-prod:///stripe-key#version={VERSION_HEX}")).unwrap();
1174        let err = b.set(&uri, "v").await.unwrap_err();
1175        let msg = format!("{err:#}");
1176        assert!(msg.contains("azure"), "names backend: {msg}");
1177        assert!(msg.contains("version"), "names offending directive: {msg}");
1178        assert!(
1179            !msg.contains("strict-mock-no-match"),
1180            "error from fragment-reject, not mock: {msg}"
1181        );
1182    }
1183
1184    // ---- delete ----
1185
1186    #[tokio::test]
1187    async fn delete_succeeds() {
1188        let dir = TempDir::new().unwrap();
1189        let mock = StrictMock::new("az")
1190            .on(
1191                &delete_argv("retired"),
1192                Response::success("{\"deletedDate\":\"...\",\"recoveryId\":\"...\"}\n"),
1193            )
1194            .install(dir.path());
1195        let b = backend(&mock, None, None);
1196        let uri = BackendUri::parse("azure-prod:///retired").unwrap();
1197        b.delete(&uri).await.unwrap();
1198    }
1199
1200    #[tokio::test]
1201    async fn delete_surfaces_secret_not_found() {
1202        let dir = TempDir::new().unwrap();
1203        let mock = StrictMock::new("az")
1204            .on(&delete_argv("retired"), Response::failure(1, "ERROR: (SecretNotFound) ...\n"))
1205            .install(dir.path());
1206        let b = backend(&mock, None, None);
1207        let uri = BackendUri::parse("azure-prod:///retired").unwrap();
1208        assert!(format!("{:#}", b.delete(&uri).await.unwrap_err()).contains("SecretNotFound"));
1209    }
1210
1211    // ---- list ----
1212
1213    #[tokio::test]
1214    async fn list_parses_json_registry_document() {
1215        let dir = TempDir::new().unwrap();
1216        let body =
1217            "{\"alpha\":\"azure-prod:///alpha-secret\",\"beta\":\"azure-prod:///beta-secret\"}";
1218        let response_body = format!("{{\"value\":{}}}\n", serde_json::to_string(body).unwrap());
1219        let mock = StrictMock::new("az")
1220            .on(&show_argv("registry-doc"), Response::success(&response_body))
1221            .install(dir.path());
1222        let b = backend(&mock, None, None);
1223        let uri = BackendUri::parse("azure-prod:///registry-doc").unwrap();
1224        let mut entries = b.list(&uri).await.unwrap();
1225        entries.sort_by(|a, b| a.0.cmp(&b.0));
1226        assert_eq!(
1227            entries,
1228            vec![
1229                ("alpha".to_owned(), "azure-prod:///alpha-secret".to_owned()),
1230                ("beta".to_owned(), "azure-prod:///beta-secret".to_owned()),
1231            ]
1232        );
1233    }
1234
1235    #[tokio::test]
1236    async fn list_errors_when_body_is_not_json_map() {
1237        let dir = TempDir::new().unwrap();
1238        let mock = StrictMock::new("az")
1239            .on(&show_argv("bad-registry"), Response::success("{\"value\":\"not-json\"}\n"))
1240            .install(dir.path());
1241        let b = backend(&mock, None, None);
1242        let uri = BackendUri::parse("azure-prod:///bad-registry").unwrap();
1243        let err = b.list(&uri).await.unwrap_err();
1244        let msg = format!("{err:#}");
1245        assert!(msg.contains("azure-prod"), "names instance: {msg}");
1246        assert!(msg.contains("alias→URI map"), "specific error: {msg}");
1247    }
1248
1249    // ---- tenant / subscription argv variants ----
1250
1251    #[tokio::test]
1252    async fn command_omits_tenant_when_not_configured() {
1253        // Declared argv has NO `--tenant` flag. A regression emitting
1254        // it would diverge from this shape.
1255        let dir = TempDir::new().unwrap();
1256        let mock = StrictMock::new("az")
1257            .on(&show_argv("x"), Response::success("{\"value\":\"v\"}\n"))
1258            .install(dir.path());
1259        let b = backend(&mock, None, None);
1260        let uri = BackendUri::parse("azure-prod:///x").unwrap();
1261        b.get(&uri).await.unwrap();
1262    }
1263
1264    #[tokio::test]
1265    async fn command_includes_tenant_when_configured() {
1266        let dir = TempDir::new().unwrap();
1267        // `az_command` emits `--tenant T` AFTER `--vault-name V` and
1268        // BEFORE `--output json`. Mirror that ordering in the declared
1269        // argv.
1270        let argv: Vec<&str> = [
1271            "keyvault",
1272            "secret",
1273            "show",
1274            "--name",
1275            "x",
1276            "--vault-name",
1277            VAULT_NAME,
1278            "--tenant",
1279            TENANT,
1280            "--output",
1281            "json",
1282        ]
1283        .to_vec();
1284        let mock = StrictMock::new("az")
1285            .on(&argv, Response::success("{\"value\":\"v\"}\n"))
1286            .install(dir.path());
1287        let b = backend(&mock, Some(TENANT), None);
1288        let uri = BackendUri::parse("azure-prod:///x").unwrap();
1289        b.get(&uri).await.unwrap();
1290    }
1291
1292    #[tokio::test]
1293    async fn command_includes_subscription_when_configured() {
1294        let dir = TempDir::new().unwrap();
1295        let argv: Vec<&str> = [
1296            "keyvault",
1297            "secret",
1298            "show",
1299            "--name",
1300            "x",
1301            "--vault-name",
1302            VAULT_NAME,
1303            "--subscription",
1304            SUB,
1305            "--output",
1306            "json",
1307        ]
1308        .to_vec();
1309        let mock = StrictMock::new("az")
1310            .on(&argv, Response::success("{\"value\":\"v\"}\n"))
1311            .install(dir.path());
1312        let b = backend(&mock, None, Some(SUB));
1313        let uri = BackendUri::parse("azure-prod:///x").unwrap();
1314        b.get(&uri).await.unwrap();
1315    }
1316
1317    // ---- drift-catch regression locks ----
1318
1319    #[tokio::test]
1320    async fn get_drift_catch_rejects_missing_vault_name() {
1321        // Declared argv INTENTIONALLY omits `--vault-name <v>`. The
1322        // real backend emits it, so the declared shape won't match
1323        // and exit 97 surfaces as a backend error.
1324        let buggy_argv: [&str; 6] = ["keyvault", "secret", "show", "--name", "x", "--output"];
1325        let dir = TempDir::new().unwrap();
1326        let mock = StrictMock::new("az")
1327            .on(&buggy_argv, Response::success("{\"value\":\"never-matches-post-fix\"}\n"))
1328            .install(dir.path());
1329        let b = backend(&mock, None, None);
1330        let uri = BackendUri::parse("azure-prod:///x").unwrap();
1331        let err = b.get(&uri).await.unwrap_err();
1332        let msg = format!("{err:#}");
1333        // Must be mock-level divergence — `unwrap_err` alone catches
1334        // the regression; this content check additionally confirms the
1335        // failure came from strict argv-mismatch, not from some other
1336        // azure-named error that could mask a different regression.
1337        assert!(msg.contains("strict-mock-no-match"), "must be mock-level divergence, got: {msg}");
1338    }
1339
1340    #[tokio::test]
1341    async fn set_drift_catch_rejects_secret_leaking_to_argv() {
1342        let secret = "sk_live_CV1_azure_regression_lock";
1343        let dir = TempDir::new().unwrap();
1344        let mock = StrictMock::new("az")
1345            .on(
1346                &set_argv("rotate-me"),
1347                Response::success_with_stdin("{}\n", vec![secret.to_owned()]),
1348            )
1349            .install(dir.path());
1350        let b = backend(&mock, None, None);
1351        let uri = BackendUri::parse("azure-prod:///rotate-me").unwrap();
1352        b.set(&uri, secret).await.unwrap();
1353    }
1354
1355    #[tokio::test]
1356    async fn check_extensive_counts_registry_entries() {
1357        // Locks the Backend-trait-default `check_extensive` behavior
1358        // (list().len()) for azure. A regression that overrode the
1359        // method with a broken impl would be caught here.
1360        let dir = TempDir::new().unwrap();
1361        let body = "{\"alpha\":\"azure-prod:///a\",\"beta\":\"azure-prod:///b\",\"gamma\":\"azure-prod:///c\"}";
1362        let response_body = format!("{{\"value\":{}}}\n", serde_json::to_string(body).unwrap());
1363        let mock = StrictMock::new("az")
1364            .on(&show_argv("reg-doc"), Response::success(&response_body))
1365            .install(dir.path());
1366        let b = backend(&mock, None, None);
1367        let uri = BackendUri::parse("azure-prod:///reg-doc").unwrap();
1368        assert_eq!(b.check_extensive(&uri).await.unwrap(), 3);
1369    }
1370
1371    #[tokio::test]
1372    async fn set_drift_catch_rejects_value_flag_on_argv() {
1373        // POSITIVE lock: declared argv carries the BUGGY `--value
1374        // <secret>` form. Post-fix code emits `--file /dev/stdin +
1375        // --encoding utf-8` instead — diverges, exit 97. Guards
1376        // against a future refactor "optimizing" small values onto
1377        // argv (which would break CV-1).
1378        let secret = "sk_live_would_leak_via_value_flag";
1379        let dir = TempDir::new().unwrap();
1380        let buggy_argv: Vec<&str> = [
1381            "keyvault",
1382            "secret",
1383            "set",
1384            "--name",
1385            "rotate-me",
1386            "--value",
1387            secret,
1388            "--vault-name",
1389            VAULT_NAME,
1390            "--output",
1391            "json",
1392        ]
1393        .to_vec();
1394        let mock =
1395            StrictMock::new("az").on(&buggy_argv, Response::success("{}\n")).install(dir.path());
1396        let b = backend(&mock, None, None);
1397        let uri = BackendUri::parse("azure-prod:///rotate-me").unwrap();
1398        // Post-fix backend emits the CV-1 argv, NOT the buggy one, so
1399        // this must FAIL (strict no-match exit 97 surfaces as error).
1400        let err = b.set(&uri, secret).await.unwrap_err();
1401        let msg = format!("{err:#}");
1402        assert!(
1403            msg.contains("strict-mock-no-match"),
1404            "must be mock-level divergence — regression emitting --value would match buggy rule: {msg}"
1405        );
1406    }
1407
1408    #[tokio::test]
1409    async fn set_drift_catch_rejects_missing_encoding_utf8() {
1410        // POSITIVE lock: declared argv omits `--encoding utf-8`. The
1411        // real backend always emits it (because the default `base64`
1412        // would corrupt the stored secret). A regression dropping the
1413        // flag would silently poison every subsequent set; this test
1414        // prevents that.
1415        let dir = TempDir::new().unwrap();
1416        let buggy_argv: Vec<&str> = [
1417            "keyvault",
1418            "secret",
1419            "set",
1420            "--name",
1421            "rotate-me",
1422            "--file",
1423            "/dev/stdin",
1424            "--vault-name",
1425            VAULT_NAME,
1426            "--output",
1427            "json",
1428        ]
1429        .to_vec();
1430        let mock = StrictMock::new("az")
1431            .on(&buggy_argv, Response::success_with_stdin("{}\n", vec!["v".to_owned()]))
1432            .install(dir.path());
1433        let b = backend(&mock, None, None);
1434        let uri = BackendUri::parse("azure-prod:///rotate-me").unwrap();
1435        let err = b.set(&uri, "v").await.unwrap_err();
1436        let msg = format!("{err:#}");
1437        assert!(
1438            msg.contains("strict-mock-no-match"),
1439            "must be mock-level divergence — regression dropping --encoding utf-8 would match buggy rule: {msg}"
1440        );
1441    }
1442}