Skip to main content

secretenv_backend_gcp/
lib.rs

1// Copyright (C) 2026 Mandeep Patel
2// SPDX-License-Identifier: AGPL-3.0-only
3
4//! Google Cloud Secret Manager backend for SecretEnv.
5//!
6//! Wraps the `gcloud` CLI — **never** a GCP SDK. Every credential
7//! chain `gcloud` supports (user login, service-account key file,
8//! Workload Identity, metadata server, impersonation) 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=<n>]` — scheme is the
15//! instance name (e.g. `gcp-prod`); path is the Secret Manager secret
16//! name. The optional `#version=<n>` directive pins a specific
17//! version ID; `<n>` is either a positive integer or the literal
18//! `latest`. When absent or `latest`, the flag is omitted and
19//! `gcloud` defaults to the newest enabled version.
20//!
21//! # Config fields
22//!
23//! - `gcp_project` (required) — passed via `--project` on every call
24//! - `gcp_impersonate_service_account` (optional) — appended as
25//!   `--impersonate-service-account <sa>` when set
26//! - `gcloud_bin` (test hook) — overrides the `gcloud` binary path
27//!
28//! # Safety
29//!
30//! Every CLI call goes through `Command::args([...])` with individual
31//! `&str`s — never `sh -c`, never `format!` into a shell string. The
32//! `set` path pipes secret values via child stdin (CV-1 discipline).
33//! The `OAuth2` bearer token returned by
34//! `gcloud auth print-access-token` is **discarded immediately** —
35//! never logged, never interpolated into identity strings, never
36//! included in error messages. A dedicated canary test locks this.
37//!
38//! See [[backends/gcp]] in the kb for the full implementation spec.
39#![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
58/// A live instance of the GCP Secret Manager backend.
59pub struct GcpBackend {
60    backend_type: &'static str,
61    instance_name: String,
62    gcp_project: String,
63    gcp_impersonate_service_account: Option<String>,
64    /// Path or name of the `gcloud` binary. Defaults to `"gcloud"`
65    /// (PATH lookup); tests override to point at a mock script.
66    gcloud_bin: String,
67    /// Per-instance fetch deadline; from `timeout_secs` config field.
68    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    /// Build a `gcloud <args...> --project <proj> --quiet
88    /// [--impersonate-service-account <sa>]` command. The positional
89    /// subcommand tokens (e.g. `secrets versions access`) go in
90    /// `args`; the trailing scoping flags are appended here so every
91    /// call site emits a consistent shape that strict mocks can lock.
92    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    /// Strip exactly one leading `/` from `uri.path` to produce the
104    /// post-strip secret name. GCP Secret Manager names CANNOT begin
105    /// with `/`; triple-slash URIs (`gcp-prod:///stripe_key`) yield
106    /// `uri.path = "/stripe_key"` which we strip to `stripe_key`.
107    fn secret_name(uri: &BackendUri) -> &str {
108        uri.path.strip_prefix('/').unwrap_or(&uri.path)
109    }
110
111    /// Resolve the `#version=<n>` directive into the positional
112    /// argument `gcloud secrets versions access` expects. Returns
113    /// `"latest"` when the fragment is absent OR the directive value
114    /// is literally `latest`. Rejects shorthand, extras, malformed
115    /// grammar, and non-integer version IDs BEFORE any network I/O.
116    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    /// Fetch the latest version with no fragment dispatch. Used by
173    /// `list` (registry documents) and reused by `get` after fragment
174    /// resolution.
175    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
200/// Validate that `name` matches the GCP Secret Manager name charset
201/// `[a-zA-Z0-9_-]{1,255}`. Cheap check performed BEFORE any `gcloud`
202/// invocation so copy-paste mistakes fail locally instead of burning
203/// an IAM permission check + subprocess.
204fn 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        // Level 1 (--version) + Level 2 proof (auth print-access-token)
241        // + identity enrichment (config get-value account) run
242        // concurrently. `print-access-token` stdout is the real OAuth2
243        // bearer token — we read ONLY its exit status and drop
244        // `output.stdout` without ever interpolating it into logs or
245        // error messages. Canary test `check_level2_auth_ok_never_logs_token_body`
246        // locks this.
247        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        // --- Level 1 ---
267        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        // --- Level 2 (token) ---
296        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        // Bearer token: drop without reading. Never log.
317        drop(token_out);
318
319        // --- Identity enrichment ---
320        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        // Fragment + secret-name validation happen BEFORE any network
342        // call so invalid URIs surface locally without burning IAM
343        // permissions, latency, or a `gcloud` subprocess. v0.2.6
344        // aws-secrets pattern.
345        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        // `versions add` creates a NEW version on an EXISTING secret.
351        // A fragment (`#version=N`) on a `set` URI is nonsensical —
352        // you cannot add a specific-numbered version. Reject before
353        // shelling out.
354        uri.reject_any_fragment("gcp")?;
355        let name = Self::secret_name(uri);
356        validate_secret_name(&self.instance_name, uri, name)?;
357
358        // Secret value is piped via child stdin — NEVER on argv. The
359        // `--data-file=/dev/stdin` sentinel tells `gcloud` to read
360        // from fd 0. Mirrors aws-secrets / vault CV-1 pattern.
361        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    /// v0.15 migrate destination path. `Native` per the v0.15 audit
402    /// table — wraps `set()` taking the value by `&Secret<String>`
403    /// reference (SEC-INV-10 borrow-not-clone; `expose_secret`
404    /// returns a `&str` borrow with the same lifetime as `value`,
405    /// no allocation).
406    async fn write_secret(&self, uri: &BackendUri, value: &Secret<String>) -> Result<()> {
407        self.set(uri, value.expose_secret()).await
408    }
409
410    /// v0.15 migrate `--delete-source` cleanup path. `Native` per
411    /// the v0.15 audit table — passthrough to `delete()`. Not called
412    /// unless the operator opts in via `--delete-source`.
413    async fn delete_secret(&self, uri: &BackendUri) -> Result<()> {
414        self.delete(uri).await
415    }
416
417    /// v0.15 migrate success-message cleanup hint — copy-paste form
418    /// of `gcloud secrets delete`.
419    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    // v0.15 migrate `--dry-run` write-permission probe: NOT
432    // overridden. Phase 7 audit (code-rev S3) flagged the prior
433    // `gcloud auth print-access-token`-and-discard probe as zero-
434    // signal latency. The full IAM-level probe (`gcloud secrets
435    // get-iam-policy` + `testIamPermissions` for
436    // `secretmanager.versions.add`) defers to v0.16+ — the
437    // destination secret may not exist yet (the whole point of
438    // migrate), so the per-secret policy probe needs a project-
439    // level fallback that deserves its own design pass. Trait
440    // default returns `Ok(())` and `has_probe_write() = false`.
441
442    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        // v0.3 `delete` removes the WHOLE SECRET (all versions). `--quiet`
447        // suppresses the confirmation prompt; the helper already appends it.
448        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        // Fetch the registry document (whole JSON map stored as a
463        // single secret value). Fragment is ignored — registry docs
464        // are always "latest".
465        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
476/// Factory for the GCP Secret Manager backend.
477pub struct GcpFactory(&'static str);
478
479impl GcpFactory {
480    /// Construct the factory. Equivalent to `GcpFactory::default()`.
481    #[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
524/// Plausibility check on the service-account email. Full validation
525/// happens server-side; we just want to catch typos before the first
526/// `gcloud` invocation.
527fn 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    /// `secrets versions access <version> --secret <name> --project
574    /// <proj> --quiet`. Shared scoping tail (`--project ... --quiet`)
575    /// is part of every argv so strict mocks implicitly lock
576    /// `--project` presence — a regression dropping it would diverge
577    /// from the declared shape and produce exit 97.
578    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    /// Install a mock that satisfies all three `check()` probes with
614    /// defaults callers can override by chaining additional rules.
615    /// `check_level1_*`/`check_level2_*` tests use this as a baseline.
616    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    // ---- Factory ----
624
625    #[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    // ---- check ----
681
682    #[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                // First line only — component list discarded.
702                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        // Canary-token defense-in-depth lock. The stdout of
726        // `gcloud auth print-access-token` is a real OAuth2 bearer
727        // token in prod. This test routes a sentinel through the
728        // mock and asserts the resulting `BackendStatus::Ok.identity`
729        // never contains the sentinel substring. A regression that
730        // format!()'d the token into the identity string (or
731        // anywhere else the status surfaces) would surface the
732        // canary and fail.
733        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    // ---- get ----
778
779    #[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        // Two trailing newlines in — only the LAST one is stripped.
805        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        // Empty-rule mock: any gcloud invocation would produce exit 97
868        // with `strict-mock-no-match`. The error MUST come from the
869        // fragment parser BEFORE any gcloud call.
870        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        // Path contains a `.` which is outside [a-zA-Z0-9_-].
923        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    // ---- set ----
934
935    #[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        // CV-1 discipline: argv carries `--data-file=/dev/stdin` sentinel
955        // (NOT the secret), and the stdin-fragment check requires the
956        // secret in stdin. Strict match on both implies "secret on
957        // stdin, NOT on argv" declaratively.
958        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        // Empty-rule mock: any gcloud invocation → exit 97. The
974        // fragment-reject happens BEFORE shelling out.
975        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    // ---- delete ----
1010
1011    #[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    // ---- list ----
1037
1038    #[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    // ---- impersonation ----
1074
1075    #[tokio::test]
1076    async fn command_omits_impersonate_when_not_configured() {
1077        // Declared argv has NO `--impersonate-service-account` suffix.
1078        // A regression emitting the flag would diverge from this shape.
1079        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        // Declared argv MUST include the impersonation flag pair at
1091        // the tail; regression dropping it produces exit 97.
1092        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    // ---- drift-catch regression locks ----
1107
1108    #[tokio::test]
1109    async fn get_drift_catch_rejects_missing_project_flag() {
1110        // Declared argv INTENTIONALLY omits `--project <proj>`. The
1111        // real backend emits it, so the declared shape WON'T match
1112        // and exit 97 surfaces as a backend error. If a regression
1113        // ever dropped `--project` from the helper, this rule would
1114        // start matching and the test would falsely pass — the
1115        // `.await.unwrap_err()` would flip to `.unwrap()`.
1116        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        // `unwrap_err` is the load-bearing regression lock. The content
1126        // check narrows to strict-mock-divergence specifically — a
1127        // weakened `msg.contains("gcp")` fallback would pass on any
1128        // unrelated gcp error and mask a different class of regression.
1129        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        // POSITIVE lock mirroring azure's `--value`-leak test. The
1135        // CV-1 sentinel on this backend is `--data-file=/dev/stdin`;
1136        // the BUGGY form would carry the secret directly on argv via
1137        // `--data=<secret>` or `--data-file=<inline-value>`. Declared
1138        // argv carries the buggy `--data=<secret>` form so the real
1139        // post-fix backend (which emits `--data-file=/dev/stdin`)
1140        // diverges, exit 97, surfacing as an error.
1141        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        // Locks the Backend-trait-default `check_extensive` behavior
1170        // (list().len()) for gcp. A regression that overrode the
1171        // method with a broken impl would be caught here.
1172        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}