1#![forbid(unsafe_code)]
40#![allow(clippy::module_name_repetitions)]
41
42use std::collections::HashMap;
43use std::io;
44use std::time::Duration;
45
46use anyhow::{anyhow, bail, Context, Result};
47use async_trait::async_trait;
48use secretenv_core::{
49 optional_duration_secs, optional_string, required_string, Backend, BackendFactory,
50 BackendStatus, BackendUri, Secret, DEFAULT_GET_TIMEOUT,
51};
52use tokio::process::Command;
53
54const CLI_NAME: &str = "gcloud";
55const INSTALL_HINT: &str =
56 "brew install --cask google-cloud-sdk OR https://cloud.google.com/sdk/docs/install";
57
58pub struct GcpBackend {
60 backend_type: &'static str,
61 instance_name: String,
62 gcp_project: String,
63 gcp_impersonate_service_account: Option<String>,
64 gcloud_bin: String,
67 timeout: Duration,
69}
70
71impl GcpBackend {
72 fn cli_missing() -> BackendStatus {
73 BackendStatus::CliMissing {
74 cli_name: CLI_NAME.to_owned(),
75 install_hint: INSTALL_HINT.to_owned(),
76 }
77 }
78
79 fn operation_failure_message(&self, uri: &BackendUri, op: &str, stderr: &[u8]) -> String {
80 let stderr_str = String::from_utf8_lossy(stderr).trim().to_owned();
81 format!(
82 "gcp backend '{}': {op} failed for URI '{}': {stderr_str}",
83 self.instance_name, uri.raw
84 )
85 }
86
87 fn gcloud_command(&self, args: &[&str]) -> Command {
93 let mut cmd = Command::new(&self.gcloud_bin);
94 cmd.args(args);
95 cmd.args(["--project", &self.gcp_project]);
96 cmd.arg("--quiet");
97 if let Some(sa) = &self.gcp_impersonate_service_account {
98 cmd.args(["--impersonate-service-account", sa]);
99 }
100 cmd
101 }
102
103 fn secret_name(uri: &BackendUri) -> &str {
108 uri.path.strip_prefix('/').unwrap_or(&uri.path)
109 }
110
111 fn resolve_version(&self, uri: &BackendUri) -> Result<String> {
117 let directives = uri.fragment_directives()?;
118 let Some(mut directives) = directives else {
119 return Ok("latest".to_owned());
120 };
121 if !directives.contains_key("version") {
122 let mut unsupported: Vec<&str> = directives.keys().map(String::as_str).collect();
123 unsupported.sort_unstable();
124 bail!(
125 "gcp backend '{}': URI '{}' has unsupported fragment directive(s) [{}]; \
126 gcp recognizes only 'version' (example: '#version=5'). \
127 See docs/fragment-vocabulary.md",
128 self.instance_name,
129 uri.raw,
130 unsupported.join(", ")
131 );
132 }
133 if directives.len() > 1 {
134 let mut extra: Vec<&str> =
135 directives.keys().filter(|k| k.as_str() != "version").map(String::as_str).collect();
136 extra.sort_unstable();
137 bail!(
138 "gcp backend '{}': URI '{}' has unsupported directive(s) [{}] alongside \
139 'version'; gcp recognizes only 'version'. \
140 See docs/fragment-vocabulary.md",
141 self.instance_name,
142 uri.raw,
143 extra.join(", ")
144 );
145 }
146 let Some(value) = directives.shift_remove("version") else {
147 unreachable!("version presence was checked above")
148 };
149 if value == "latest" {
150 return Ok("latest".to_owned());
151 }
152 let parsed: u64 = value.parse().map_err(|_| {
153 anyhow!(
154 "gcp backend '{}': URI '{}' has invalid version value '{}'; \
155 expected positive integer or 'latest'",
156 self.instance_name,
157 uri.raw,
158 value
159 )
160 })?;
161 if parsed == 0 {
162 bail!(
163 "gcp backend '{}': URI '{}' has invalid version value '0'; \
164 versions start at 1",
165 self.instance_name,
166 uri.raw
167 );
168 }
169 Ok(parsed.to_string())
170 }
171
172 async fn get_raw(&self, uri: &BackendUri, version: &str) -> Result<String> {
176 let name = Self::secret_name(uri);
177 validate_secret_name(&self.instance_name, uri, name)?;
178 let mut cmd =
179 self.gcloud_command(&["secrets", "versions", "access", version, "--secret", name]);
180 let output = cmd.output().await.with_context(|| {
181 format!(
182 "gcp backend '{}': failed to invoke 'gcloud secrets versions access' \
183 for URI '{}'",
184 self.instance_name, uri.raw
185 )
186 })?;
187 if !output.status.success() {
188 bail!(self.operation_failure_message(uri, "get", &output.stderr));
189 }
190 let stdout = String::from_utf8(output.stdout).with_context(|| {
191 format!(
192 "gcp backend '{}': non-UTF-8 response for URI '{}'",
193 self.instance_name, uri.raw
194 )
195 })?;
196 Ok(stdout.strip_suffix('\n').unwrap_or(&stdout).to_owned())
197 }
198}
199
200fn validate_secret_name(instance_name: &str, uri: &BackendUri, name: &str) -> Result<()> {
205 if name.is_empty() || name.len() > 255 {
206 bail!(
207 "gcp backend '{instance_name}': URI '{}' has invalid secret name \
208 (length {}); must be 1..=255 chars",
209 uri.raw,
210 name.len()
211 );
212 }
213 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
214 bail!(
215 "gcp backend '{instance_name}': URI '{}' has invalid secret name '{}'; \
216 GCP Secret Manager names allow only [a-zA-Z0-9_-]",
217 uri.raw,
218 name
219 );
220 }
221 Ok(())
222}
223
224#[async_trait]
225impl Backend for GcpBackend {
226 fn backend_type(&self) -> &str {
227 self.backend_type
228 }
229
230 fn instance_name(&self) -> &str {
231 &self.instance_name
232 }
233
234 fn timeout(&self) -> Duration {
235 self.timeout
236 }
237
238 #[allow(clippy::similar_names)]
239 async fn check(&self) -> BackendStatus {
240 let version_fut = Command::new(&self.gcloud_bin).arg("--version").output();
248
249 let mut token_cmd = Command::new(&self.gcloud_bin);
250 token_cmd.args(["auth", "print-access-token"]);
251 if let Some(sa) = &self.gcp_impersonate_service_account {
252 token_cmd.args(["--impersonate-service-account", sa]);
253 }
254 let token_fut = token_cmd.output();
255
256 let mut account_cmd = Command::new(&self.gcloud_bin);
257 account_cmd.args(["config", "get-value", "account"]);
258 if let Some(sa) = &self.gcp_impersonate_service_account {
259 account_cmd.args(["--impersonate-service-account", sa]);
260 }
261 let account_fut = account_cmd.output();
262
263 let (version_res, token_res, account_res) =
264 tokio::join!(version_fut, token_fut, account_fut);
265
266 let version_out = match version_res {
268 Ok(o) => o,
269 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::cli_missing(),
270 Err(e) => {
271 return BackendStatus::Error {
272 message: format!(
273 "gcp backend '{}': failed to invoke '{}': {e}",
274 self.instance_name, self.gcloud_bin
275 ),
276 };
277 }
278 };
279 if !version_out.status.success() {
280 return BackendStatus::Error {
281 message: format!(
282 "gcp backend '{}': 'gcloud --version' exited non-zero: {}",
283 self.instance_name,
284 String::from_utf8_lossy(&version_out.stderr).trim()
285 ),
286 };
287 }
288 let cli_version = String::from_utf8_lossy(&version_out.stdout)
289 .lines()
290 .next()
291 .unwrap_or("unknown")
292 .trim()
293 .to_owned();
294
295 let token_out = match token_res {
297 Ok(o) => o,
298 Err(e) => {
299 return BackendStatus::Error {
300 message: format!(
301 "gcp backend '{}': failed to invoke auth print-access-token: {e}",
302 self.instance_name
303 ),
304 };
305 }
306 };
307 if !token_out.status.success() {
308 let stderr = String::from_utf8_lossy(&token_out.stderr).trim().to_owned();
309 return BackendStatus::NotAuthenticated {
310 hint: format!(
311 "run: gcloud auth login OR gcloud auth activate-service-account \
312 --key-file <path> (stderr: {stderr})"
313 ),
314 };
315 }
316 drop(token_out);
318
319 let account = match account_res {
321 Ok(o) if o.status.success() => {
322 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
323 if s.is_empty() {
324 "(unset)".to_owned()
325 } else {
326 s
327 }
328 }
329 _ => "(unset)".to_owned(),
330 };
331
332 let identity = self.gcp_impersonate_service_account.as_ref().map_or_else(
333 || format!("account={account} project={}", self.gcp_project),
334 |sa| format!("account={account} project={} impersonate={sa}", self.gcp_project),
335 );
336
337 BackendStatus::Ok { cli_version, identity }
338 }
339
340 async fn get(&self, uri: &BackendUri) -> Result<Secret<String>> {
341 let version = self.resolve_version(uri)?;
346 self.get_raw(uri, &version).await.map(Secret::new)
347 }
348
349 async fn set(&self, uri: &BackendUri, value: &str) -> Result<()> {
350 uri.reject_any_fragment("gcp")?;
355 let name = Self::secret_name(uri);
356 validate_secret_name(&self.instance_name, uri, name)?;
357
358 let mut cmd =
362 self.gcloud_command(&["secrets", "versions", "add", name, "--data-file=/dev/stdin"]);
363 cmd.stdin(std::process::Stdio::piped());
364 cmd.stdout(std::process::Stdio::piped());
365 cmd.stderr(std::process::Stdio::piped());
366 let mut child = cmd.spawn().with_context(|| {
367 format!(
368 "gcp backend '{}': failed to spawn 'gcloud secrets versions add' \
369 for URI '{}'",
370 self.instance_name, uri.raw
371 )
372 })?;
373 if let Some(mut stdin) = child.stdin.take() {
374 use tokio::io::AsyncWriteExt;
375 match stdin.write_all(value.as_bytes()).await {
376 Ok(()) => {}
377 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
378 Err(e) => {
379 return Err(anyhow::Error::new(e).context(format!(
380 "gcp backend '{}': failed to write secret value to gcloud stdin",
381 self.instance_name
382 )));
383 }
384 }
385 stdin.shutdown().await.ok();
386 drop(stdin);
387 }
388 let output = child.wait_with_output().await.with_context(|| {
389 format!(
390 "gcp backend '{}': 'gcloud secrets versions add' exited abnormally \
391 for URI '{}'",
392 self.instance_name, uri.raw
393 )
394 })?;
395 if !output.status.success() {
396 bail!(self.operation_failure_message(uri, "set", &output.stderr));
397 }
398 Ok(())
399 }
400
401 async fn write_secret(&self, uri: &BackendUri, value: &Secret<String>) -> Result<()> {
407 self.set(uri, value.expose_secret()).await
408 }
409
410 async fn delete_secret(&self, uri: &BackendUri) -> Result<()> {
414 self.delete(uri).await
415 }
416
417 fn delete_hint(&self, uri: &BackendUri) -> String {
420 let name = Self::secret_name(uri);
421 let impersonate = self
422 .gcp_impersonate_service_account
423 .as_deref()
424 .map_or_else(String::new, |sa| format!(" --impersonate-service-account {sa}"));
425 format!(
426 "gcloud secrets delete {name} --project {project} --quiet{impersonate}",
427 project = self.gcp_project,
428 )
429 }
430
431 async fn delete(&self, uri: &BackendUri) -> Result<()> {
443 uri.reject_any_fragment("gcp")?;
444 let name = Self::secret_name(uri);
445 validate_secret_name(&self.instance_name, uri, name)?;
446 let mut cmd = self.gcloud_command(&["secrets", "delete", name]);
449 let output = cmd.output().await.with_context(|| {
450 format!(
451 "gcp backend '{}': failed to invoke 'gcloud secrets delete' for URI '{}'",
452 self.instance_name, uri.raw
453 )
454 })?;
455 if !output.status.success() {
456 bail!(self.operation_failure_message(uri, "delete", &output.stderr));
457 }
458 Ok(())
459 }
460
461 async fn list(&self, uri: &BackendUri) -> Result<Vec<(String, String)>> {
462 let body = self.get_raw(uri, "latest").await?;
466 let map: HashMap<String, String> = serde_json::from_str(&body).with_context(|| {
467 format!(
468 "gcp backend '{}': secret body at '{}' is not a JSON alias→URI map",
469 self.instance_name, uri.raw
470 )
471 })?;
472 Ok(map.into_iter().collect())
473 }
474}
475
476pub struct GcpFactory(&'static str);
478
479impl GcpFactory {
480 #[must_use]
482 pub const fn new() -> Self {
483 Self("gcp")
484 }
485}
486
487impl Default for GcpFactory {
488 fn default() -> Self {
489 Self::new()
490 }
491}
492
493impl BackendFactory for GcpFactory {
494 fn backend_type(&self) -> &str {
495 self.0
496 }
497
498 fn create(
499 &self,
500 instance_name: &str,
501 config: &HashMap<String, toml::Value>,
502 ) -> Result<Box<dyn Backend>> {
503 let gcp_project = required_string(config, "gcp_project", "gcp", instance_name)?;
504 let gcp_impersonate_service_account =
505 optional_string(config, "gcp_impersonate_service_account", "gcp", instance_name)?;
506 if let Some(sa) = &gcp_impersonate_service_account {
507 validate_impersonate_email("gcp", instance_name, sa)?;
508 }
509 let gcloud_bin = optional_string(config, "gcloud_bin", "gcp", instance_name)?
510 .unwrap_or_else(|| CLI_NAME.to_owned());
511 let timeout = optional_duration_secs(config, "timeout_secs", "gcp", instance_name)?
512 .unwrap_or(DEFAULT_GET_TIMEOUT);
513 Ok(Box::new(GcpBackend {
514 backend_type: "gcp",
515 instance_name: instance_name.to_owned(),
516 gcp_project,
517 gcp_impersonate_service_account,
518 gcloud_bin,
519 timeout,
520 }))
521 }
522}
523
524fn validate_impersonate_email(backend_type: &str, instance_name: &str, sa: &str) -> Result<()> {
528 if !sa.contains('@') || !sa.ends_with(".iam.gserviceaccount.com") {
529 bail!(
530 "{backend_type} instance '{instance_name}': field \
531 'gcp_impersonate_service_account' value '{sa}' does not look like a \
532 service-account email (expected '<name>@<project>.iam.gserviceaccount.com')"
533 );
534 }
535 Ok(())
536}
537
538#[cfg(test)]
539#[allow(clippy::unwrap_used, clippy::expect_used)]
540mod tests {
541 use std::path::Path;
542
543 use secretenv_testing::{Response, StrictMock};
544 use tempfile::TempDir;
545
546 use super::*;
547
548 const PROJECT: &str = "my-project-prod";
549 const SA: &str = "secretenv-reader@my-proj.iam.gserviceaccount.com";
550
551 fn backend(mock_path: &Path, impersonate: Option<&str>) -> GcpBackend {
552 GcpBackend {
553 backend_type: "gcp",
554 instance_name: "gcp-prod".to_owned(),
555 gcp_project: PROJECT.to_owned(),
556 gcp_impersonate_service_account: impersonate.map(ToOwned::to_owned),
557 gcloud_bin: mock_path.to_str().unwrap().to_owned(),
558 timeout: DEFAULT_GET_TIMEOUT,
559 }
560 }
561
562 fn backend_with_nonexistent_gcloud() -> GcpBackend {
563 GcpBackend {
564 backend_type: "gcp",
565 instance_name: "gcp-prod".to_owned(),
566 gcp_project: PROJECT.to_owned(),
567 gcp_impersonate_service_account: None,
568 gcloud_bin: "/definitely/not/a/real/path/to/gcloud-binary-XYZ".to_owned(),
569 timeout: DEFAULT_GET_TIMEOUT,
570 }
571 }
572
573 fn get_argv<'a>(name: &'a str, version: &'a str) -> [&'a str; 9] {
579 [
580 "secrets",
581 "versions",
582 "access",
583 version,
584 "--secret",
585 name,
586 "--project",
587 PROJECT,
588 "--quiet",
589 ]
590 }
591
592 fn add_argv(name: &str) -> [&str; 8] {
593 [
594 "secrets",
595 "versions",
596 "add",
597 name,
598 "--data-file=/dev/stdin",
599 "--project",
600 PROJECT,
601 "--quiet",
602 ]
603 }
604
605 fn delete_argv(name: &str) -> [&str; 6] {
606 ["secrets", "delete", name, "--project", PROJECT, "--quiet"]
607 }
608
609 const VERSION_ARGV: &[&str] = &["--version"];
610 const AUTH_ARGV: &[&str] = &["auth", "print-access-token"];
611 const ACCOUNT_ARGV: &[&str] = &["config", "get-value", "account"];
612
613 fn check_mock_ok(_dir: &Path) -> StrictMock {
617 StrictMock::new("gcloud")
618 .on(VERSION_ARGV, Response::success("Google Cloud SDK 468.0.0\nbq 2.0\n"))
619 .on(AUTH_ARGV, Response::success("ya29.dummy-token\n"))
620 .on(ACCOUNT_ARGV, Response::success("alice@example.com\n"))
621 }
622
623 #[test]
626 fn factory_backend_type_is_gcp() {
627 assert_eq!(GcpFactory::new().backend_type(), "gcp");
628 }
629
630 #[test]
631 fn factory_errors_when_gcp_project_missing() {
632 let factory = GcpFactory::new();
633 let cfg: HashMap<String, toml::Value> = HashMap::new();
634 let Err(err) = factory.create("gcp-prod", &cfg) else {
635 panic!("expected error when gcp_project is missing");
636 };
637 let msg = format!("{err:#}");
638 assert!(msg.contains("gcp_project"), "names missing field: {msg}");
639 assert!(msg.contains("gcp-prod"), "names instance: {msg}");
640 }
641
642 #[test]
643 fn factory_accepts_project_and_no_impersonate() {
644 let factory = GcpFactory::new();
645 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
646 cfg.insert("gcp_project".to_owned(), toml::Value::String(PROJECT.to_owned()));
647 let b = factory.create("gcp-prod", &cfg).unwrap();
648 assert_eq!(b.backend_type(), "gcp");
649 assert_eq!(b.instance_name(), "gcp-prod");
650 }
651
652 #[test]
653 fn factory_rejects_non_string_gcp_project() {
654 let factory = GcpFactory::new();
655 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
656 cfg.insert("gcp_project".to_owned(), toml::Value::Integer(1));
657 let Err(err) = factory.create("gcp-prod", &cfg) else {
658 panic!("expected type error");
659 };
660 assert!(format!("{err:#}").contains("must be a string"));
661 }
662
663 #[test]
664 fn factory_rejects_malformed_impersonate_email() {
665 let factory = GcpFactory::new();
666 let mut cfg: HashMap<String, toml::Value> = HashMap::new();
667 cfg.insert("gcp_project".to_owned(), toml::Value::String(PROJECT.to_owned()));
668 cfg.insert(
669 "gcp_impersonate_service_account".to_owned(),
670 toml::Value::String("not-an-email".to_owned()),
671 );
672 let Err(err) = factory.create("gcp-prod", &cfg) else {
673 panic!("expected error when gcp_impersonate_service_account is malformed");
674 };
675 let msg = format!("{err:#}");
676 assert!(msg.contains("gcp_impersonate_service_account"), "names field: {msg}");
677 assert!(msg.contains("service-account email"), "explains shape: {msg}");
678 }
679
680 #[tokio::test]
683 async fn check_cli_missing_on_enoent() {
684 let b = backend_with_nonexistent_gcloud();
685 match b.check().await {
686 BackendStatus::CliMissing { cli_name, install_hint } => {
687 assert_eq!(cli_name, "gcloud");
688 assert!(install_hint.contains("google-cloud-sdk"));
689 }
690 other => panic!("expected CliMissing, got {other:?}"),
691 }
692 }
693
694 #[tokio::test]
695 async fn check_level1_version_ok() {
696 let dir = TempDir::new().unwrap();
697 let mock = check_mock_ok(dir.path()).install(dir.path());
698 let b = backend(&mock, None);
699 match b.check().await {
700 BackendStatus::Ok { cli_version, .. } => {
701 assert_eq!(cli_version, "Google Cloud SDK 468.0.0");
703 }
704 other => panic!("expected Ok, got {other:?}"),
705 }
706 }
707
708 #[tokio::test]
709 async fn check_level2_auth_ok() {
710 let dir = TempDir::new().unwrap();
711 let mock = check_mock_ok(dir.path()).install(dir.path());
712 let b = backend(&mock, None);
713 match b.check().await {
714 BackendStatus::Ok { identity, .. } => {
715 assert!(identity.contains("account=alice@example.com"), "identity: {identity}");
716 assert!(identity.contains("project=my-project-prod"), "identity: {identity}");
717 assert!(!identity.contains("impersonate"), "no impersonation: {identity}");
718 }
719 other => panic!("expected Ok, got {other:?}"),
720 }
721 }
722
723 #[tokio::test]
724 async fn check_level2_auth_ok_never_logs_token_body() {
725 const CANARY: &str = "CANARY-TOKEN-NEVER-IN-LOGS";
734 let dir = TempDir::new().unwrap();
735 let mock = StrictMock::new("gcloud")
736 .on(VERSION_ARGV, Response::success("Google Cloud SDK 468.0.0\n"))
737 .on(AUTH_ARGV, Response::success(format!("ya29.{CANARY}\n")))
738 .on(ACCOUNT_ARGV, Response::success("alice@example.com\n"))
739 .install(dir.path());
740 let b = backend(&mock, None);
741 let status = b.check().await;
742 let BackendStatus::Ok { cli_version, identity } = status else {
743 panic!("expected Ok, got {status:?}");
744 };
745 assert!(
746 !cli_version.contains(CANARY),
747 "canary must not leak into cli_version: {cli_version}"
748 );
749 assert!(!identity.contains(CANARY), "canary must not leak into identity: {identity}");
750 }
751
752 #[tokio::test]
753 async fn check_level2_not_authenticated() {
754 let dir = TempDir::new().unwrap();
755 let mock = StrictMock::new("gcloud")
756 .on(VERSION_ARGV, Response::success("Google Cloud SDK 468.0.0\n"))
757 .on(
758 AUTH_ARGV,
759 Response::failure(
760 1,
761 "ERROR: (gcloud.auth.print-access-token) You do not currently have \
762 an active account selected.\n",
763 ),
764 )
765 .on(ACCOUNT_ARGV, Response::success("(unset)\n"))
766 .install(dir.path());
767 let b = backend(&mock, None);
768 match b.check().await {
769 BackendStatus::NotAuthenticated { hint } => {
770 assert!(hint.contains("gcloud auth login"), "hint: {hint}");
771 assert!(hint.contains("activate-service-account"), "hint: {hint}");
772 }
773 other => panic!("expected NotAuthenticated, got {other:?}"),
774 }
775 }
776
777 #[tokio::test]
780 async fn get_returns_secret_latest() {
781 let dir = TempDir::new().unwrap();
782 let mock = StrictMock::new("gcloud")
783 .on(&get_argv("stripe_key", "latest"), Response::success("sk_live_abc\n"))
784 .install(dir.path());
785 let b = backend(&mock, None);
786 let uri = BackendUri::parse("gcp-prod:///stripe_key").unwrap();
787 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "sk_live_abc");
788 }
789
790 #[tokio::test]
791 async fn get_returns_secret_at_version_5() {
792 let dir = TempDir::new().unwrap();
793 let mock = StrictMock::new("gcloud")
794 .on(&get_argv("stripe_key", "5"), Response::success("older\n"))
795 .install(dir.path());
796 let b = backend(&mock, None);
797 let uri = BackendUri::parse("gcp-prod:///stripe_key#version=5").unwrap();
798 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "older");
799 }
800
801 #[tokio::test]
802 async fn get_strips_single_trailing_newline() {
803 let dir = TempDir::new().unwrap();
804 let mock = StrictMock::new("gcloud")
806 .on(&get_argv("multi_line", "latest"), Response::success("line1\nline2\n"))
807 .install(dir.path());
808 let b = backend(&mock, None);
809 let uri = BackendUri::parse("gcp-prod:///multi_line").unwrap();
810 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "line1\nline2");
811 }
812
813 #[tokio::test]
814 async fn get_empty_value_returns_empty_string() {
815 let dir = TempDir::new().unwrap();
816 let mock = StrictMock::new("gcloud")
817 .on(&get_argv("empty_secret", "latest"), Response::success("\n"))
818 .install(dir.path());
819 let b = backend(&mock, None);
820 let uri = BackendUri::parse("gcp-prod:///empty_secret").unwrap();
821 assert_eq!(b.get(&uri).await.unwrap().expose_secret(), "");
822 }
823
824 #[tokio::test]
825 async fn get_not_found_wraps_stderr() {
826 let dir = TempDir::new().unwrap();
827 let mock = StrictMock::new("gcloud")
828 .on(
829 &get_argv("missing", "latest"),
830 Response::failure(
831 1,
832 "ERROR: (gcloud.secrets.versions.access) NOT_FOUND: \
833 Secret [projects/my-project-prod/secrets/missing] not found.\n",
834 ),
835 )
836 .install(dir.path());
837 let b = backend(&mock, None);
838 let uri = BackendUri::parse("gcp-prod:///missing").unwrap();
839 let err = b.get(&uri).await.unwrap_err();
840 let msg = format!("{err:#}");
841 assert!(msg.contains("gcp-prod"), "names instance: {msg}");
842 assert!(msg.contains("NOT_FOUND"), "passes through: {msg}");
843 }
844
845 #[tokio::test]
846 async fn get_failed_precondition_destroyed_version() {
847 let dir = TempDir::new().unwrap();
848 let mock = StrictMock::new("gcloud")
849 .on(
850 &get_argv("rotated", "5"),
851 Response::failure(
852 1,
853 "ERROR: (gcloud.secrets.versions.access) FAILED_PRECONDITION: \
854 Secret version [projects/p/secrets/rotated/versions/5] is in \
855 state DESTROYED.\n",
856 ),
857 )
858 .install(dir.path());
859 let b = backend(&mock, None);
860 let uri = BackendUri::parse("gcp-prod:///rotated#version=5").unwrap();
861 let err = b.get(&uri).await.unwrap_err();
862 assert!(format!("{err:#}").contains("DESTROYED"));
863 }
864
865 #[tokio::test]
866 async fn get_rejects_shorthand_fragment() {
867 let dir = TempDir::new().unwrap();
871 let mock = StrictMock::new("gcloud").install(dir.path());
872 let b = backend(&mock, None);
873 let uri = BackendUri::parse("gcp-prod:///stripe_key#password").unwrap();
874 let err = b.get(&uri).await.unwrap_err();
875 let msg = format!("{err:#}");
876 assert!(msg.contains("shorthand"), "error names the problem: {msg}");
877 assert!(
878 !msg.contains("strict-mock-no-match"),
879 "error must come from fragment parser, not mock: {msg}"
880 );
881 }
882
883 #[tokio::test]
884 async fn get_rejects_unsupported_directive() {
885 let dir = TempDir::new().unwrap();
886 let mock = StrictMock::new("gcloud").install(dir.path());
887 let b = backend(&mock, None);
888 let uri = BackendUri::parse("gcp-prod:///stripe_key#json-key=password").unwrap();
889 let err = b.get(&uri).await.unwrap_err();
890 let msg = format!("{err:#}");
891 assert!(msg.contains("unsupported"), "error names the problem: {msg}");
892 assert!(msg.contains("json-key"), "lists offender: {msg}");
893 assert!(msg.contains("version"), "names the supported directive: {msg}");
894 assert!(msg.contains("fragment-vocabulary"), "error links to canonical doc: {msg}");
895 assert!(
896 !msg.contains("strict-mock-no-match"),
897 "error must come from backend, not mock: {msg}"
898 );
899 }
900
901 #[tokio::test]
902 async fn get_rejects_invalid_version_value() {
903 let dir = TempDir::new().unwrap();
904 let mock = StrictMock::new("gcloud").install(dir.path());
905 let b = backend(&mock, None);
906 let uri = BackendUri::parse("gcp-prod:///stripe_key#version=abc").unwrap();
907 let err = b.get(&uri).await.unwrap_err();
908 let msg = format!("{err:#}");
909 assert!(msg.contains("invalid version value"), "error names the problem: {msg}");
910 assert!(msg.contains("'abc'"), "quotes the bad value: {msg}");
911 assert!(
912 !msg.contains("strict-mock-no-match"),
913 "error must come from backend, not mock: {msg}"
914 );
915 }
916
917 #[tokio::test]
918 async fn get_rejects_invalid_secret_name() {
919 let dir = TempDir::new().unwrap();
920 let mock = StrictMock::new("gcloud").install(dir.path());
921 let b = backend(&mock, None);
922 let uri = BackendUri::parse("gcp-prod:///bad.name").unwrap();
924 let err = b.get(&uri).await.unwrap_err();
925 let msg = format!("{err:#}");
926 assert!(msg.contains("invalid secret name"), "error names the problem: {msg}");
927 assert!(
928 !msg.contains("strict-mock-no-match"),
929 "error must come from backend, not mock: {msg}"
930 );
931 }
932
933 #[tokio::test]
936 async fn set_succeeds_on_zero_exit() {
937 let dir = TempDir::new().unwrap();
938 let mock = StrictMock::new("gcloud")
939 .on(
940 &add_argv("rotate_me"),
941 Response::success_with_stdin(
942 "Created version [6] of the secret [rotate_me].\n",
943 vec!["new-val".to_owned()],
944 ),
945 )
946 .install(dir.path());
947 let b = backend(&mock, None);
948 let uri = BackendUri::parse("gcp-prod:///rotate_me").unwrap();
949 b.set(&uri, "new-val").await.unwrap();
950 }
951
952 #[tokio::test]
953 async fn set_passes_secret_value_via_stdin_not_argv() {
954 let very_sensitive = "sk_live_TOP_SECRET_gcp_never_argv_XYZ";
959 let dir = TempDir::new().unwrap();
960 let mock = StrictMock::new("gcloud")
961 .on(
962 &add_argv("stripe_key"),
963 Response::success_with_stdin("ok\n", vec![very_sensitive.to_owned()]),
964 )
965 .install(dir.path());
966 let b = backend(&mock, None);
967 let uri = BackendUri::parse("gcp-prod:///stripe_key").unwrap();
968 b.set(&uri, very_sensitive).await.unwrap();
969 }
970
971 #[tokio::test]
972 async fn set_rejects_fragment_on_uri() {
973 let dir = TempDir::new().unwrap();
976 let mock = StrictMock::new("gcloud").install(dir.path());
977 let b = backend(&mock, None);
978 let uri = BackendUri::parse("gcp-prod:///stripe_key#version=5").unwrap();
979 let err = b.set(&uri, "v").await.unwrap_err();
980 let msg = format!("{err:#}");
981 assert!(msg.contains("gcp"), "names backend: {msg}");
982 assert!(msg.contains("version"), "names offending directive: {msg}");
983 assert!(
984 !msg.contains("strict-mock-no-match"),
985 "error must come from fragment-reject, not mock: {msg}"
986 );
987 }
988
989 #[tokio::test]
990 async fn set_propagates_not_found() {
991 let dir = TempDir::new().unwrap();
992 let mock = StrictMock::new("gcloud")
993 .on(
994 &add_argv("nonexistent"),
995 Response::failure(
996 1,
997 "ERROR: (gcloud.secrets.versions.add) NOT_FOUND: Secret \
998 [nonexistent] not found.\n",
999 )
1000 .with_env_absent("NEVER_SET_SENTINEL"),
1001 )
1002 .install(dir.path());
1003 let b = backend(&mock, None);
1004 let uri = BackendUri::parse("gcp-prod:///nonexistent").unwrap();
1005 let err = b.set(&uri, "v").await.unwrap_err();
1006 assert!(format!("{err:#}").contains("NOT_FOUND"));
1007 }
1008
1009 #[tokio::test]
1012 async fn delete_succeeds() {
1013 let dir = TempDir::new().unwrap();
1014 let mock = StrictMock::new("gcloud")
1015 .on(&delete_argv("retired"), Response::success("Deleted secret [retired].\n"))
1016 .install(dir.path());
1017 let b = backend(&mock, None);
1018 let uri = BackendUri::parse("gcp-prod:///retired").unwrap();
1019 b.delete(&uri).await.unwrap();
1020 }
1021
1022 #[tokio::test]
1023 async fn delete_already_gone_surfaces_not_found() {
1024 let dir = TempDir::new().unwrap();
1025 let mock = StrictMock::new("gcloud")
1026 .on(
1027 &delete_argv("retired"),
1028 Response::failure(1, "ERROR: (gcloud.secrets.delete) NOT_FOUND: ...\n"),
1029 )
1030 .install(dir.path());
1031 let b = backend(&mock, None);
1032 let uri = BackendUri::parse("gcp-prod:///retired").unwrap();
1033 assert!(format!("{:#}", b.delete(&uri).await.unwrap_err()).contains("NOT_FOUND"));
1034 }
1035
1036 #[tokio::test]
1039 async fn list_parses_json_registry() {
1040 let dir = TempDir::new().unwrap();
1041 let body =
1042 "{\"alpha\":\"gcp-prod:///alpha_secret\",\"beta\":\"gcp-prod:///beta_secret\"}\n";
1043 let mock = StrictMock::new("gcloud")
1044 .on(&get_argv("registry_doc", "latest"), Response::success(body))
1045 .install(dir.path());
1046 let b = backend(&mock, None);
1047 let uri = BackendUri::parse("gcp-prod:///registry_doc").unwrap();
1048 let mut entries = b.list(&uri).await.unwrap();
1049 entries.sort_by(|a, b| a.0.cmp(&b.0));
1050 assert_eq!(
1051 entries,
1052 vec![
1053 ("alpha".to_owned(), "gcp-prod:///alpha_secret".to_owned()),
1054 ("beta".to_owned(), "gcp-prod:///beta_secret".to_owned()),
1055 ]
1056 );
1057 }
1058
1059 #[tokio::test]
1060 async fn list_errors_when_body_is_not_json() {
1061 let dir = TempDir::new().unwrap();
1062 let mock = StrictMock::new("gcloud")
1063 .on(&get_argv("bad_registry", "latest"), Response::success("not-json\n"))
1064 .install(dir.path());
1065 let b = backend(&mock, None);
1066 let uri = BackendUri::parse("gcp-prod:///bad_registry").unwrap();
1067 let err = b.list(&uri).await.unwrap_err();
1068 let msg = format!("{err:#}");
1069 assert!(msg.contains("gcp-prod"), "names instance: {msg}");
1070 assert!(msg.contains("alias→URI map"), "specific error: {msg}");
1071 }
1072
1073 #[tokio::test]
1076 async fn command_omits_impersonate_when_not_configured() {
1077 let dir = TempDir::new().unwrap();
1080 let mock = StrictMock::new("gcloud")
1081 .on(&get_argv("x", "latest"), Response::success("v\n"))
1082 .install(dir.path());
1083 let b = backend(&mock, None);
1084 let uri = BackendUri::parse("gcp-prod:///x").unwrap();
1085 b.get(&uri).await.unwrap();
1086 }
1087
1088 #[tokio::test]
1089 async fn command_includes_impersonate_when_configured() {
1090 let dir = TempDir::new().unwrap();
1093 let argv_with_sa: Vec<&str> = get_argv("x", "latest")
1094 .iter()
1095 .copied()
1096 .chain(["--impersonate-service-account", SA])
1097 .collect();
1098 let mock = StrictMock::new("gcloud")
1099 .on(&argv_with_sa, Response::success("v\n"))
1100 .install(dir.path());
1101 let b = backend(&mock, Some(SA));
1102 let uri = BackendUri::parse("gcp-prod:///x").unwrap();
1103 b.get(&uri).await.unwrap();
1104 }
1105
1106 #[tokio::test]
1109 async fn get_drift_catch_rejects_missing_project_flag() {
1110 let buggy_argv: [&str; 6] = ["secrets", "versions", "access", "latest", "--secret", "x"];
1117 let dir = TempDir::new().unwrap();
1118 let mock = StrictMock::new("gcloud")
1119 .on(&buggy_argv, Response::success("never-matches-post-fix\n"))
1120 .install(dir.path());
1121 let b = backend(&mock, None);
1122 let uri = BackendUri::parse("gcp-prod:///x").unwrap();
1123 let err = b.get(&uri).await.unwrap_err();
1124 let msg = format!("{err:#}");
1125 assert!(msg.contains("strict-mock-no-match"), "must be mock-level divergence, got: {msg}");
1130 }
1131
1132 #[tokio::test]
1133 async fn set_drift_catch_rejects_data_flag_on_argv() {
1134 let secret = "sk_live_would_leak_via_data_flag_gcp";
1142 let dir = TempDir::new().unwrap();
1143 let buggy_argv: Vec<&str> = vec![
1144 "secrets",
1145 "versions",
1146 "add",
1147 "rotate_me",
1148 "--data",
1149 secret,
1150 "--project",
1151 PROJECT,
1152 "--quiet",
1153 ];
1154 let mock = StrictMock::new("gcloud")
1155 .on(&buggy_argv, Response::success("ok\n"))
1156 .install(dir.path());
1157 let b = backend(&mock, None);
1158 let uri = BackendUri::parse("gcp-prod:///rotate_me").unwrap();
1159 let err = b.set(&uri, secret).await.unwrap_err();
1160 let msg = format!("{err:#}");
1161 assert!(
1162 msg.contains("strict-mock-no-match"),
1163 "must be mock-level divergence — regression emitting --data=<secret> would match: {msg}"
1164 );
1165 }
1166
1167 #[tokio::test]
1168 async fn check_extensive_counts_registry_entries() {
1169 let dir = TempDir::new().unwrap();
1173 let body = "{\"alpha\":\"gcp-prod:///a\",\"beta\":\"gcp-prod:///b\",\"gamma\":\"gcp-prod:///c\"}\n";
1174 let mock = StrictMock::new("gcloud")
1175 .on(&get_argv("reg_doc", "latest"), Response::success(body))
1176 .install(dir.path());
1177 let b = backend(&mock, None);
1178 let uri = BackendUri::parse("gcp-prod:///reg_doc").unwrap();
1179 assert_eq!(b.check_extensive(&uri).await.unwrap(), 3);
1180 }
1181
1182 #[tokio::test]
1183 async fn set_drift_catch_rejects_secret_leaking_to_argv() {
1184 let secret = "sk_live_CV1_gcp_regression_lock";
1185 let dir = TempDir::new().unwrap();
1186 let mock = StrictMock::new("gcloud")
1187 .on(
1188 &add_argv("rotate_me"),
1189 Response::success_with_stdin("ok\n", vec![secret.to_owned()]),
1190 )
1191 .install(dir.path());
1192 let b = backend(&mock, None);
1193 let uri = BackendUri::parse("gcp-prod:///rotate_me").unwrap();
1194 b.set(&uri, secret).await.unwrap();
1195 }
1196}