1use hasp_core::{
21 Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
22};
23use serde::Deserialize;
24use url::Url;
25
26#[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
82pub 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Deserialize)]
483struct AccessResponse {
484 payload: Option<SecretData>,
485}
486
487#[derive(Debug, Deserialize)]
488struct SecretData {
489 data: String,
490}
491
492#[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#[derive(Debug, Deserialize)]
508struct SecretListItem {
509 name: String,
510}
511
512fn 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
526fn 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 }
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 #[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}