Skip to main content

hasp_backend_gcp_sm/
lib.rs

1//! `gcp-sm://` backend for hasp.
2//!
3//! Grammar: `gcp-sm://<project-id>/<secret-id>?version=<version>&field=<path>`
4//!   - `<project-id>` — GCP project identifier (host). Must be non-empty.
5//!   - `<secret-id>`  — Secret ID (path). Identifiers must match
6//!     `^[a-zA-Z0-9-_]{1,255}$` per GCP. Leading `/` is stripped.
7//!   - `?version`     — Optional version label. Defaults to `latest`.
8//!   - `?field`       — Optional dotted JSON path. When set, the
9//!     decoded secret value is parsed as JSON and the named scalar is
10//!     returned (see `hasp_core::extract_field`). Non-JSON payloads
11//!     fail with `InvalidUrl`.
12//!
13//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
14//!
15//! Authentication is ambient only: `GOOGLE_APPLICATION_CREDENTIALS`
16//! or VM metadata service. `gcp-auth` resolves these transparently.
17//! No auth-bootstrap flows or credential refresh logic lives in this
18//! crate.
19
20use hasp_core::{
21    Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
22};
23use serde::Deserialize;
24use url::Url;
25
26/// URL shape for `gcp-sm://` addresses.
27///
28/// `project_id` and `secret_id` are identifiers, not secret values.
29/// They may appear in error messages (redacted per URL discipline).
30#[derive(Debug)]
31pub struct GcpSmUrl {
32    pub project_id: String,
33    pub secret_id: String,
34    pub version: String,
35    pub field: Option<String>,
36}
37
38impl TryFrom<&Url> for GcpSmUrl {
39    type Error = Error;
40
41    fn try_from(url: &Url) -> Result<Self, Self::Error> {
42        if url.scheme() != "gcp-sm" {
43            return Err(Error::InvalidUrl("expected gcp-sm:// scheme".into()));
44        }
45
46        let project_id = url
47            .host_str()
48            .ok_or_else(|| Error::InvalidUrl("gcp-sm:// requires a project-id (host)".into()))?
49            .to_owned();
50        if project_id.is_empty() {
51            return Err(Error::InvalidUrl(
52                "gcp-sm:// project-id must not be empty".into(),
53            ));
54        }
55
56        let secret_id = url.path().trim_start_matches('/').to_owned();
57
58        let mut version = String::from("latest");
59        let mut field = None;
60
61        for (k, v) in url.query_pairs() {
62            match k.as_ref() {
63                "version" => version = v.into_owned(),
64                "field" => field = Some(v.into_owned()),
65                _ => {
66                    return Err(Error::InvalidUrl(format!(
67                        "gcp-sm:// unknown query parameter: {k}"
68                    )));
69                }
70            }
71        }
72
73        Ok(GcpSmUrl {
74            project_id,
75            secret_id,
76            version,
77            field,
78        })
79    }
80}
81
82/// Google Cloud Secret Manager REST backend.
83///
84/// Construction attempts to build a Tokio runtime inside a `Mutex` so the
85/// async GCP credential flow can be used from the sync `Backend` trait.
86/// The runtime is `current_thread` to keep the backend lightweight. If
87/// runtime creation fails, the error is stored and replayed on first
88/// use.
89pub struct GcpSmBackend {
90    init: Result<tokio::runtime::Runtime, Error>,
91    proxy: Option<ProxyConfig>,
92}
93
94impl std::fmt::Debug for GcpSmBackend {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("GcpSmBackend")
97            .field("init", &self.init.is_ok())
98            .field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
99            .finish()
100    }
101}
102
103impl GcpSmBackend {
104    const SCHEME: &'static str = "gcp-sm";
105    const BASE_URL: &'static str = "https://secretmanager.googleapis.com/v1";
106
107    /// Create a new `GcpSmBackend`.
108    ///
109    /// Errors on construction are deferred to first use so
110    /// `Store::with_defaults()` never panics.
111    pub fn new() -> Self {
112        Self::with_proxy(None)
113    }
114
115    pub fn with_proxy(proxy: Option<ProxyConfig>) -> Self {
116        Self {
117            proxy,
118            init: tokio::runtime::Builder::new_current_thread()
119                .enable_io()
120                .enable_time()
121                .build()
122                .map_err(|e| Error::Backend {
123                    scheme: Self::SCHEME,
124                    kind: BackendFailureKind::Permanent,
125                    message: format!("failed to create tokio runtime: {e}"),
126                }),
127        }
128    }
129
130    fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
131        self.init.as_ref().map_err(|e| e.clone())
132    }
133
134    fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
135    where
136        F: std::future::Future,
137    {
138        let rt = self.runtime()?;
139        Ok(rt.block_on(future))
140    }
141
142    const SCOPES: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"];
143
144    /// Obtain a fresh access token.
145    fn token(&self) -> Result<String, Error> {
146        self.block_on(async {
147            let provider = gcp_auth::provider().await.map_err(|e| Error::Backend {
148                scheme: Self::SCHEME,
149                kind: BackendFailureKind::Permanent,
150                message: format!("failed to discover GCP credentials: {e}"),
151            })?;
152            let token = provider
153                .token(Self::SCOPES)
154                .await
155                .map_err(|e| Error::Backend {
156                    scheme: Self::SCHEME,
157                    kind: BackendFailureKind::Permanent,
158                    message: format!("failed to acquire GCP access token: {e}"),
159                })?;
160            Ok(token.as_str().to_owned())
161        })?
162    }
163
164    /// Build a `reqwest::blocking::Client` ready for GCP.
165    fn client(&self) -> reqwest::blocking::Client {
166        let mut builder =
167            reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10));
168
169        if let Some(p) = &self.proxy {
170            let proxy = reqwest::Proxy::all(p.url_without_credentials())
171                .expect("reqwest proxy construction is infallible with a valid URL");
172            builder = builder.proxy(proxy);
173        }
174
175        builder
176            .build()
177            .expect("reqwest client construction is infallible with default features")
178    }
179}
180
181impl Default for GcpSmBackend {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl Backend for GcpSmBackend {
188    fn scheme(&self) -> &'static str {
189        Self::SCHEME
190    }
191
192    fn validate(&self, url: &Url) -> Result<(), Error> {
193        GcpSmUrl::try_from(url).map(|_| ())
194    }
195
196    fn get(&self, url: &Url) -> Result<SecretString, Error> {
197        let gcp_url = GcpSmUrl::try_from(url)?;
198        if gcp_url.secret_id.is_empty() {
199            return Err(Error::InvalidUrl(
200                "gcp-sm:// secret-id must not be empty".into(),
201            ));
202        }
203        let token = self.token()?;
204
205        let request_url = format!(
206            "{}/projects/{}/secrets/{}/versions/{}:access",
207            Self::BASE_URL,
208            gcp_url.project_id,
209            gcp_url.secret_id,
210            gcp_url.version,
211        );
212
213        let client = self.client();
214        let response = client
215            .get(&request_url)
216            .bearer_auth(&token)
217            .send()
218            .map_err(map_reqwest_error)?;
219
220        let status = response.status();
221        if !status.is_success() {
222            return Err(map_http_status(status, url));
223        }
224
225        let payload: AccessResponse = response.json().map_err(|e| Error::Backend {
226            scheme: Self::SCHEME,
227            kind: BackendFailureKind::Permanent,
228            message: format!("invalid JSON from GCP Secret Manager: {e}"),
229        })?;
230
231        let decoded = payload
232            .payload
233            .ok_or_else(|| Error::Backend {
234                scheme: Self::SCHEME,
235                kind: BackendFailureKind::Permanent,
236                message: "GCP Secret Manager returned a secret version without data".into(),
237            })?
238            .data;
239
240        // GCP returns base64-encoded binary data; we decode it here.
241        // If the data is plain text JSON, it is still base64. We decode
242        // to bytes and then interpret as UTF-8, matching the hasp
243        // text-oriented contract.
244        let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &decoded)
245            .map_err(|e| Error::Backend {
246                scheme: Self::SCHEME,
247                kind: BackendFailureKind::Permanent,
248                message: format!("failed to decode base64 secret value: {e}"),
249            })?;
250
251        let text = String::from_utf8(bytes).map_err(|e| Error::Backend {
252            scheme: Self::SCHEME,
253            kind: BackendFailureKind::Permanent,
254            message: format!("secret value is not valid UTF-8: {e}"),
255        })?;
256
257        // Field extraction runs on the parsed JSON before wrapping in
258        // `SecretString` — the parent payload never escapes this function
259        // as a plaintext `String`.
260        let value = match &gcp_url.field {
261            Some(path) => hasp_core::extract_field_from_str(&text, path)?,
262            None => text,
263        };
264        Ok(SecretString::new(value.into()))
265    }
266
267    fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
268        let gcp_url = GcpSmUrl::try_from(url)?;
269        if gcp_url.secret_id.is_empty() {
270            return Err(Error::InvalidUrl(
271                "gcp-sm:// secret-id must not be empty".into(),
272            ));
273        }
274        let token = self.token()?;
275
276        // Try to create the secret. If it already exists (409), skip.
277        let create_url = format!("{}/projects/{}/secrets", Self::BASE_URL, gcp_url.project_id,);
278
279        let create_body = serde_json::json!({
280            "replication": { "automatic": {} },
281        });
282
283        let client = self.client();
284        let create_response = client
285            .post(&create_url)
286            .bearer_auth(&token)
287            .json(&create_body)
288            .send()
289            .map_err(map_reqwest_error)?;
290
291        // 409 AlreadyExists is fine — just add a new version.
292        if !create_response.status().is_success()
293            && create_response.status() != reqwest::StatusCode::CONFLICT
294        {
295            return Err(map_http_status(create_response.status(), url));
296        }
297
298        // Add secret version.
299        let add_version_url = format!(
300            "{}/projects/{}/secrets/{}/versions:add",
301            Self::BASE_URL,
302            gcp_url.project_id,
303            gcp_url.secret_id,
304        );
305
306        // GCP Secret Manager expects base64-encoded payload.
307        let payload = base64::Engine::encode(
308            &base64::engine::general_purpose::STANDARD,
309            value.expose_secret().as_bytes(),
310        );
311        let version_body = serde_json::json!({ "payload": { "data": payload } });
312
313        let version_response = client
314            .post(&add_version_url)
315            .bearer_auth(&token)
316            .json(&version_body)
317            .send()
318            .map_err(map_reqwest_error)?;
319
320        if !version_response.status().is_success() {
321            return Err(map_http_status(version_response.status(), url));
322        }
323
324        Ok(())
325    }
326
327    fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
328        let gcp_url = GcpSmUrl::try_from(url)?;
329        let token = self.token()?;
330
331        let prefix = gcp_url.secret_id.trim_matches('/');
332
333        let mut request_url = Url::parse(&format!(
334            "{}/projects/{}/secrets",
335            Self::BASE_URL,
336            gcp_url.project_id
337        ))
338        .map_err(|e| Error::Backend {
339            scheme: Self::SCHEME,
340            kind: BackendFailureKind::Permanent,
341            message: format!("failed to build list URL: {e}"),
342        })?;
343
344        if !prefix.is_empty() {
345            request_url.query_pairs_mut().append_pair(
346                "filter",
347                &format!("name:projects/{}/secrets/{}", gcp_url.project_id, prefix),
348            );
349        }
350
351        let client = self.client();
352        let mut entries = Vec::new();
353        const MAX_PAGES: usize = 500;
354
355        for _ in 0..MAX_PAGES {
356            let response = client
357                .get(request_url.as_str())
358                .bearer_auth(&token)
359                .send()
360                .map_err(map_reqwest_error)?;
361
362            let status = response.status();
363            if !status.is_success() {
364                return Err(map_http_status(status, url));
365            }
366
367            let payload: SecretListResponse = response.json().map_err(|e| Error::Backend {
368                scheme: Self::SCHEME,
369                kind: BackendFailureKind::Permanent,
370                message: format!("invalid JSON from GCP Secret Manager list: {e}"),
371            })?;
372
373            for secret in payload.secrets.into_iter().flatten() {
374                let name = secret.name;
375                if name.is_empty() {
376                    continue;
377                }
378                let entry_url = Url::parse(&format!("gcp-sm://{}/{name}", gcp_url.project_id,))
379                    .map_err(|e| Error::Backend {
380                        scheme: Self::SCHEME,
381                        kind: BackendFailureKind::Permanent,
382                        message: format!("failed to build list entry URL: {e}"),
383                    })?;
384                entries.push(Entry {
385                    name,
386                    url: entry_url,
387                });
388            }
389
390            match payload.next_page_token {
391                Some(ref t) if !t.is_empty() => {
392                    request_url = Url::parse(&format!(
393                        "{}/projects/{}/secrets?pageToken={}",
394                        Self::BASE_URL,
395                        gcp_url.project_id,
396                        t,
397                    ))
398                    .map_err(|e| Error::Backend {
399                        scheme: Self::SCHEME,
400                        kind: BackendFailureKind::Permanent,
401                        message: format!("failed to build paginated list URL: {e}"),
402                    })?;
403                    if !prefix.is_empty() {
404                        request_url.query_pairs_mut().append_pair(
405                            "filter",
406                            &format!("name:projects/{}/secrets/{}", gcp_url.project_id, prefix),
407                        );
408                    }
409                }
410                _ => break,
411            }
412        }
413
414        Ok(entries)
415    }
416
417    fn delete(&self, url: &Url) -> Result<(), Error> {
418        let gcp_url = GcpSmUrl::try_from(url)?;
419        if gcp_url.secret_id.is_empty() {
420            return Err(Error::InvalidUrl(
421                "gcp-sm:// secret-id must not be empty".into(),
422            ));
423        }
424        let token = self.token()?;
425
426        let request_url = format!(
427            "{}/projects/{}/secrets/{}",
428            Self::BASE_URL,
429            gcp_url.project_id,
430            gcp_url.secret_id,
431        );
432
433        let client = self.client();
434        let response = client
435            .delete(&request_url)
436            .bearer_auth(&token)
437            .send()
438            .map_err(map_reqwest_error)?;
439
440        if !response.status().is_success() {
441            return Err(map_http_status(response.status(), url));
442        }
443
444        Ok(())
445    }
446
447    fn exists(&self, url: &Url) -> Result<bool, Error> {
448        let gcp_url = GcpSmUrl::try_from(url)?;
449        if gcp_url.secret_id.is_empty() {
450            return Err(Error::InvalidUrl(
451                "gcp-sm:// secret-id must not be empty".into(),
452            ));
453        }
454        let token = self.token()?;
455
456        // A lightweight metadata-only call: GetSecret (not GetSecretVersion).
457        let request_url = format!(
458            "{}/projects/{}/secrets/{}",
459            Self::BASE_URL,
460            gcp_url.project_id,
461            gcp_url.secret_id,
462        );
463
464        let client = self.client();
465        let response = client
466            .get(&request_url)
467            .bearer_auth(&token)
468            .send()
469            .map_err(map_reqwest_error)?;
470
471        match response.status() {
472            reqwest::StatusCode::OK => Ok(true),
473            reqwest::StatusCode::NOT_FOUND => Ok(false),
474            status => Err(map_http_status(status, url)),
475        }
476    }
477}
478
479/// Response body from the `:access` endpoint.
480///
481/// `payload.data` is a base64-encoded string.
482#[derive(Debug, Deserialize)]
483struct AccessResponse {
484    payload: Option<SecretData>,
485}
486
487#[derive(Debug, Deserialize)]
488struct SecretData {
489    data: String,
490}
491
492/// Response body from the GCP Secret Manager `ListSecrets` endpoint.
493///
494/// `secrets` is an array of secret metadata. `nextPageToken` is the token
495/// for the next page; it is followed transparently up to a bounded limit.
496#[derive(Debug, Deserialize)]
497struct SecretListResponse {
498    #[serde(default)]
499    secrets: Option<Vec<SecretListItem>>,
500    #[serde(rename = "nextPageToken")]
501    next_page_token: Option<String>,
502}
503
504/// A single secret entry in the GCP Secret Manager list response.
505///
506/// `name` is the full resource name like `projects/my-project/secrets/my-secret`.
507#[derive(Debug, Deserialize)]
508struct SecretListItem {
509    name: String,
510}
511
512/// Map `reqwest` network errors into the locked `hasp_core::Error` taxonomy.
513fn map_reqwest_error(err: reqwest::Error) -> Error {
514    let kind = if err.is_timeout() || err.is_connect() {
515        BackendFailureKind::Transient
516    } else {
517        BackendFailureKind::Permanent
518    };
519    Error::Backend {
520        scheme: "gcp-sm",
521        kind,
522        message: format!("GCP request failed: {err}"),
523    }
524}
525
526/// Map GCP HTTP status codes into the locked `hasp_core::Error` taxonomy.
527///
528/// Reference:
529/// <https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets.versions/access>
530// TODO(#4): validate against live GCP project — see notes/TODO-live-error-mapping.md
531fn map_http_status(status: reqwest::StatusCode, url: &Url) -> Error {
532    match status {
533        reqwest::StatusCode::NOT_FOUND => Error::NotFound(url.to_string()),
534        reqwest::StatusCode::FORBIDDEN => {
535            Error::PermissionDenied(format!("gcp-sm:// permission denied for {url}"))
536        }
537        reqwest::StatusCode::UNAUTHORIZED => {
538            Error::AuthenticationFailed(format!("gcp-sm:// authentication failed for {url}"))
539        }
540        reqwest::StatusCode::TOO_MANY_REQUESTS => Error::Backend {
541            scheme: "gcp-sm",
542            kind: BackendFailureKind::Throttled,
543            message: format!("GCP Secret Manager throttled the request (HTTP {status})"),
544        },
545        status if status.is_server_error() => Error::Backend {
546            scheme: "gcp-sm",
547            kind: BackendFailureKind::Transient,
548            message: format!("GCP Secret Manager returned HTTP {status}"),
549        },
550        status if status.as_u16() == 409 => {
551            Error::PreconditionFailed(format!("gcp-sm:// precondition failed (HTTP {status})"))
552        }
553        status if status.as_u16() == 400 => {
554            Error::InvalidUrl(format!("gcp-sm:// invalid request (HTTP {status})"))
555        }
556        _ => Error::Backend {
557            scheme: "gcp-sm",
558            kind: BackendFailureKind::Permanent,
559            message: format!("GCP Secret Manager returned HTTP {status}"),
560        },
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn parse_valid_url_simple() {
570        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
571        let gcp = GcpSmUrl::try_from(&url).unwrap();
572        assert_eq!(gcp.project_id, "my-project");
573        assert_eq!(gcp.secret_id, "my-secret");
574        assert_eq!(gcp.version, "latest");
575    }
576
577    #[test]
578    fn parse_valid_url_with_path() {
579        let url = Url::parse("gcp-sm://my-project/secrets/app/db-password").unwrap();
580        let gcp = GcpSmUrl::try_from(&url).unwrap();
581        assert_eq!(gcp.project_id, "my-project");
582        assert_eq!(gcp.secret_id, "secrets/app/db-password");
583    }
584
585    #[test]
586    fn parse_valid_url_with_version() {
587        let url = Url::parse("gcp-sm://my-project/my-secret?version=3").unwrap();
588        let gcp = GcpSmUrl::try_from(&url).unwrap();
589        assert_eq!(gcp.version, "3");
590    }
591
592    #[test]
593    fn parse_valid_url_with_field() {
594        let url = Url::parse("gcp-sm://my-project/my-secret?field=.creds.password").unwrap();
595        let gcp = GcpSmUrl::try_from(&url).unwrap();
596        assert_eq!(gcp.field, Some(".creds.password".into()));
597        assert_eq!(gcp.version, "latest");
598    }
599
600    #[test]
601    fn parse_missing_host_fails() {
602        let url = Url::parse("gcp-sm:///my-secret").unwrap();
603        assert!(GcpSmUrl::try_from(&url).is_err());
604    }
605
606    #[test]
607    fn parse_empty_path_allowed_for_list() {
608        let url = Url::parse("gcp-sm://my-project/").unwrap();
609        let gcp = GcpSmUrl::try_from(&url).unwrap();
610        assert_eq!(gcp.project_id, "my-project");
611        assert_eq!(gcp.secret_id, "");
612    }
613
614    #[test]
615    fn empty_secret_id_fails_at_operation() {
616        let backend = GcpSmBackend::new();
617        let url = Url::parse("gcp-sm://my-project/").unwrap();
618        let dummy = SecretString::new("x".into());
619        assert!(
620            matches!(
621                backend.get(&url),
622                Err(Error::InvalidUrl(ref s)) if s.contains("secret-id must not be empty")
623            ),
624            "empty secret-id should fail at operation boundary"
625        );
626        assert!(
627            matches!(
628                backend.put(&url, &dummy),
629                Err(Error::InvalidUrl(ref s)) if s.contains("secret-id must not be empty")
630            ),
631            "empty secret-id should fail at operation boundary for put"
632        );
633        assert!(
634            matches!(
635                backend.delete(&url),
636                Err(Error::InvalidUrl(ref s)) if s.contains("secret-id must not be empty")
637            ),
638            "empty secret-id should fail at operation boundary for delete"
639        );
640        assert!(
641            matches!(
642                backend.exists(&url),
643                Err(Error::InvalidUrl(ref s)) if s.contains("secret-id must not be empty")
644            ),
645            "empty secret-id should fail at operation boundary for exists"
646        );
647    }
648
649    #[test]
650    fn parse_unknown_query_fails() {
651        let url = Url::parse("gcp-sm://my-project/my-secret?raw=true").unwrap();
652        assert!(GcpSmUrl::try_from(&url).is_err());
653    }
654
655    #[test]
656    fn error_map_404_to_not_found() {
657        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
658        let err = map_http_status(reqwest::StatusCode::NOT_FOUND, &url);
659        assert!(matches!(err, Error::NotFound(ref s) if s == "gcp-sm://my-project/my-secret"));
660    }
661
662    #[test]
663    fn error_map_403_to_permission_denied() {
664        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
665        let err = map_http_status(reqwest::StatusCode::FORBIDDEN, &url);
666        assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("permission denied")));
667    }
668
669    #[test]
670    fn error_map_401_to_authentication_failed() {
671        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
672        let err = map_http_status(reqwest::StatusCode::UNAUTHORIZED, &url);
673        assert!(
674            matches!(err, Error::AuthenticationFailed(ref s) if s.contains("authentication failed"))
675        );
676    }
677
678    #[test]
679    fn error_map_429_to_throttled() {
680        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
681        let err = map_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &url);
682        assert!(matches!(
683            err,
684            Error::Backend {
685                kind: BackendFailureKind::Throttled,
686                ..
687            }
688        ));
689    }
690
691    #[test]
692    fn error_map_500_to_transient() {
693        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
694        let err = map_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &url);
695        assert!(matches!(
696            err,
697            Error::Backend {
698                kind: BackendFailureKind::Transient,
699                ..
700            }
701        ));
702    }
703
704    #[test]
705    fn error_map_418_to_permanent() {
706        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
707        let err = map_http_status(reqwest::StatusCode::IM_A_TEAPOT, &url);
708        assert!(matches!(
709            err,
710            Error::Backend {
711                kind: BackendFailureKind::Permanent,
712                ..
713            }
714        ));
715    }
716
717    #[test]
718    fn error_map_400_to_invalid_url() {
719        let url = Url::parse("gcp-sm://my-project/my-secret").unwrap();
720        let err = map_http_status(reqwest::StatusCode::BAD_REQUEST, &url);
721        assert!(matches!(err, Error::InvalidUrl(ref s) if s.contains("invalid request")));
722    }
723
724    #[test]
725    fn supported_operations() {
726        let _backend = GcpSmBackend::new();
727        // put, list, delete are now implemented; they fail at network layer
728        // because no GCP credentials are configured in unit tests.
729        // Constructing a reqwest::Client without initializing rustls would
730        // panic; verifying the backend type is sufficient here.
731    }
732
733    #[test]
734    fn list_parsing_with_next_page_token() {
735        let payload: SecretListResponse =
736            serde_json::from_str(r#"{"secrets":[{"name":"my-secret"}],"nextPageToken":"abc123"}"#)
737                .unwrap();
738
739        let items = payload.secrets.unwrap();
740        assert_eq!(items.len(), 1);
741        assert_eq!(payload.next_page_token.unwrap(), "abc123");
742    }
743
744    #[test]
745    fn backend_scheme() {
746        let backend = GcpSmBackend::new();
747        assert_eq!(backend.scheme(), "gcp-sm");
748    }
749
750    // GCP returns secret bytes base64-decoded by get(); the post-decode
751    // UTF-8 string is the JSON payload the user stored. These tests
752    // exercise the shared helper on representative payloads.
753    #[test]
754    fn field_extraction_happy() {
755        let payload = r#"{"db":{"password":"hunter2"}}"#;
756        let v = hasp_core::extract_field_from_str(payload, ".db.password").unwrap();
757        assert_eq!(v, "hunter2");
758    }
759
760    #[test]
761    fn field_extraction_missing_field_is_not_found() {
762        let payload = r#"{"db":{}}"#;
763        let err = hasp_core::extract_field_from_str(payload, ".db.password").unwrap_err();
764        assert!(matches!(err, Error::NotFound(_)));
765    }
766
767    #[test]
768    fn field_extraction_non_json_is_invalid_url() {
769        let payload = "raw-bytes-not-json";
770        let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
771        assert!(matches!(err, Error::InvalidUrl(_)));
772    }
773}