1#![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
68fn vault_url_re() -> &'static Regex {
73 static RE: OnceLock<Regex> = OnceLock::new();
74 #[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
92fn 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
100fn vault_name_from_url(url: &str) -> &str {
103 let after_scheme = url.trim_start_matches("https://");
106 after_scheme.split('.').next().unwrap_or(after_scheme)
107}
108
109pub struct AzureBackend {
111 backend_type: &'static str,
112 instance_name: String,
113 #[allow(dead_code)] azure_vault_url: String,
115 vault_name: String,
116 azure_tenant: Option<String>,
117 azure_subscription: Option<String>,
118 az_bin: String,
119 timeout: Duration,
121}
122
123#[derive(Deserialize)]
124struct SecretShowResponse {
125 value: Option<String>,
133 #[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 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 fn secret_name(uri: &BackendUri) -> &str {
201 uri.path.strip_prefix('/').unwrap_or(&uri.path)
202 }
203
204 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 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
307fn 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 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 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 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 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 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 uri.reject_any_fragment("azure")?;
453 let name = Self::secret_name(uri);
454 validate_secret_name(&self.instance_name, uri, name)?;
455
456 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 async fn write_secret(&self, uri: &BackendUri, value: &Secret<String>) -> Result<()> {
510 self.set(uri, value.expose_secret()).await
511 }
512
513 async fn delete_secret(&self, uri: &BackendUri) -> Result<()> {
517 self.delete(uri).await
518 }
519
520 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 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 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
571pub struct AzureFactory(&'static str);
573
574impl AzureFactory {
575 #[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 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 #[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 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 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 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 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 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 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/", "https://my-kv.evil.azure.net/", ] {
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 #[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 #[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 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 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 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 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 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 #[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 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 #[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 #[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 #[tokio::test]
1252 async fn command_omits_tenant_when_not_configured() {
1253 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 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 #[tokio::test]
1320 async fn get_drift_catch_rejects_missing_vault_name() {
1321 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 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 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 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 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 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}