1use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
31use url::Url;
32
33#[derive(Debug)]
38pub struct AwsSmUrl {
39 pub region: String,
40 pub secret_name: String,
41 pub version_stage: Option<String>,
42 pub version_id: Option<String>,
43 pub field: Option<String>,
44}
45
46impl TryFrom<&Url> for AwsSmUrl {
47 type Error = Error;
48
49 fn try_from(url: &Url) -> Result<Self, Self::Error> {
50 if url.scheme() != "aws-sm" {
51 return Err(Error::InvalidUrl("expected aws-sm:// scheme".into()));
52 }
53
54 let region = url
55 .host_str()
56 .ok_or_else(|| Error::InvalidUrl("aws-sm:// requires a region (host)".into()))?
57 .to_owned();
58 if region.is_empty() {
59 return Err(Error::InvalidUrl(
60 "aws-sm:// region must not be empty".into(),
61 ));
62 }
63
64 let secret_name = url.path().trim_start_matches('/').to_owned();
65
66 let mut version_stage = None;
67 let mut version_id = None;
68 let mut field = None;
69
70 for (k, v) in url.query_pairs() {
71 match k.as_ref() {
72 "version-stage" => version_stage = Some(v.into_owned()),
73 "version-id" => version_id = Some(v.into_owned()),
74 "field" => field = Some(v.into_owned()),
75 _ => {
76 return Err(Error::InvalidUrl(format!(
77 "aws-sm:// unknown query parameter: {k}"
78 )));
79 }
80 }
81 }
82
83 if version_stage.is_some() && version_id.is_some() {
84 return Err(Error::InvalidUrl(
85 "aws-sm:// version-stage and version-id are mutually exclusive".into(),
86 ));
87 }
88
89 Ok(AwsSmUrl {
90 region,
91 secret_name,
92 version_stage,
93 version_id,
94 field,
95 })
96 }
97}
98
99#[derive(Debug)]
110pub struct AwsSmBackend {
111 init: Result<tokio::runtime::Runtime, Error>,
112}
113
114impl AwsSmBackend {
115 pub fn new() -> Self {
120 Self {
121 init: tokio::runtime::Builder::new_current_thread()
122 .enable_io()
123 .enable_time()
124 .build()
125 .map_err(|e| Error::Backend {
126 scheme: "aws-sm",
127 kind: BackendFailureKind::Permanent,
128 message: format!("failed to create tokio runtime: {e}"),
129 }),
130 }
131 }
132
133 pub fn with_proxy(_proxy: Option<hasp_core::ProxyConfig>) -> Self {
139 Self::new()
140 }
141
142 fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
143 self.init.as_ref().map_err(|e| e.clone())
144 }
145
146 fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
147 where
148 F: std::future::Future,
149 {
150 let rt = self.runtime()?;
151 Ok(rt.block_on(future))
152 }
153}
154
155impl Default for AwsSmBackend {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl Backend for AwsSmBackend {
162 fn scheme(&self) -> &'static str {
163 "aws-sm"
164 }
165
166 fn validate(&self, url: &Url) -> Result<(), Error> {
167 AwsSmUrl::try_from(url).map(|_| ())
168 }
169
170 fn get(&self, url: &Url) -> Result<SecretString, Error> {
171 let aws_url = AwsSmUrl::try_from(url)?;
172 if aws_url.secret_name.is_empty() {
173 return Err(Error::InvalidUrl(
174 "aws-sm:// secret name must not be empty".into(),
175 ));
176 }
177 self.block_on(get_secret(&aws_url))?
178 }
179
180 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
181 let aws_url = AwsSmUrl::try_from(url)?;
182 if aws_url.secret_name.is_empty() {
183 return Err(Error::InvalidUrl(
184 "aws-sm:// secret name must not be empty".into(),
185 ));
186 }
187 self.block_on(put_secret(&aws_url, value.expose_secret()))?
188 }
189
190 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
191 let aws_url = AwsSmUrl::try_from(url)?;
192 self.block_on(list_secrets(&aws_url))?
193 }
194
195 fn delete(&self, url: &Url) -> Result<(), Error> {
196 let aws_url = AwsSmUrl::try_from(url)?;
197 if aws_url.secret_name.is_empty() {
198 return Err(Error::InvalidUrl(
199 "aws-sm:// secret name must not be empty".into(),
200 ));
201 }
202 self.block_on(delete_secret(&aws_url))?
203 }
204
205 fn exists(&self, url: &Url) -> Result<bool, Error> {
206 let aws_url = AwsSmUrl::try_from(url)?;
207 if aws_url.secret_name.is_empty() {
208 return Err(Error::InvalidUrl(
209 "aws-sm:// secret name must not be empty".into(),
210 ));
211 }
212 match self.block_on(describe_secret(&aws_url))? {
213 Ok(()) => Ok(true),
214 Err(Error::NotFound(_)) => Ok(false),
215 Err(e) => Err(e),
216 }
217 }
218}
219
220async fn aws_config_for_region(region: &str) -> aws_config::SdkConfig {
222 aws_config::defaults(aws_config::BehaviorVersion::latest())
223 .region(aws_config::Region::new(region.to_string()))
224 .load()
225 .await
226}
227
228async fn get_secret(aws_url: &AwsSmUrl) -> Result<SecretString, Error> {
232 let config = aws_config_for_region(&aws_url.region).await;
233 let client = aws_sdk_secretsmanager::Client::new(&config);
234
235 let mut builder = client.get_secret_value().secret_id(&aws_url.secret_name);
236
237 if let Some(stage) = &aws_url.version_stage {
238 builder = builder.version_stage(stage);
239 }
240 if let Some(id) = &aws_url.version_id {
241 builder = builder.version_id(id);
242 }
243
244 let output = builder.send().await.map_err(map_get_error)?;
245
246 let text = match output.secret_string {
247 Some(t) => t,
248 None => {
249 if output.secret_binary.is_some() {
250 return Err(Error::Backend {
251 scheme: "aws-sm",
252 kind: BackendFailureKind::Permanent,
253 message: "secret contains binary data; aws-sm:// only supports text secrets"
254 .into(),
255 });
256 }
257 return Err(Error::Backend {
258 scheme: "aws-sm",
259 kind: BackendFailureKind::Permanent,
260 message: "AWS returned a secret with neither text nor binary value".into(),
261 });
262 }
263 };
264
265 let value = match &aws_url.field {
269 Some(path) => hasp_core::extract_field_from_str(&text, path)?,
270 None => text,
271 };
272 Ok(SecretString::new(value.into()))
273}
274
275async fn describe_secret(aws_url: &AwsSmUrl) -> Result<(), Error> {
279 let config = aws_config_for_region(&aws_url.region).await;
280 let client = aws_sdk_secretsmanager::Client::new(&config);
281
282 client
283 .describe_secret()
284 .secret_id(&aws_url.secret_name)
285 .send()
286 .await
287 .map_err(map_describe_error)?;
288
289 Ok(())
290}
291
292async fn put_secret(aws_url: &AwsSmUrl, value: &str) -> Result<(), Error> {
297 let config = aws_config_for_region(&aws_url.region).await;
298 let client = aws_sdk_secretsmanager::Client::new(&config);
299
300 let create_result = client
301 .create_secret()
302 .name(&aws_url.secret_name)
303 .secret_string(value)
304 .send()
305 .await;
306
307 match create_result {
308 Ok(_) => Ok(()),
309 Err(err) => {
310 if let Some(service_err) = err.as_service_error() {
311 let code = service_err.meta().code().unwrap_or("Unknown");
312 if code == "AlreadyExistsException" {
313 client
314 .put_secret_value()
315 .secret_id(&aws_url.secret_name)
316 .secret_string(value)
317 .send()
318 .await
319 .map_err(map_put_error)?;
320 Ok(())
321 } else {
322 Err(map_create_error(err))
323 }
324 } else {
325 Err(map_generic_error(err))
326 }
327 }
328 }
329}
330
331async fn list_secrets(aws_url: &AwsSmUrl) -> Result<Vec<Entry>, Error> {
336 let config = aws_config_for_region(&aws_url.region).await;
337 let client = aws_sdk_secretsmanager::Client::new(&config);
338
339 let mut entries = Vec::new();
340 let mut next_token: Option<String> = None;
341 const MAX_PAGES: usize = 500;
342
343 for _ in 0..MAX_PAGES {
344 let mut builder = client.list_secrets();
345 if let Some(ref token) = next_token {
346 builder = builder.next_token(token);
347 }
348
349 let output = builder.send().await.map_err(map_list_error)?;
350 next_token = output.next_token.clone();
351
352 for secret in output.secret_list.into_iter().flatten() {
353 let name = secret.name.unwrap_or_default();
354 if name.is_empty() {
355 continue;
356 }
357 let entry_url =
358 Url::parse(&format!("aws-sm://{}/{name}", aws_url.region)).map_err(|e| {
359 Error::Backend {
360 scheme: "aws-sm",
361 kind: BackendFailureKind::Permanent,
362 message: format!("failed to build list entry URL: {e}"),
363 }
364 })?;
365 entries.push(Entry {
366 name,
367 url: entry_url,
368 });
369 }
370
371 if next_token.is_none() {
372 break;
373 }
374 }
375
376 Ok(entries)
377}
378
379async fn delete_secret(aws_url: &AwsSmUrl) -> Result<(), Error> {
384 let config = aws_config_for_region(&aws_url.region).await;
385 let client = aws_sdk_secretsmanager::Client::new(&config);
386
387 client
388 .delete_secret()
389 .secret_id(&aws_url.secret_name)
390 .force_delete_without_recovery(false)
391 .send()
392 .await
393 .map_err(map_delete_error)?;
394
395 Ok(())
396}
397
398fn map_get_error(
401 err: aws_sdk_secretsmanager::error::SdkError<
402 aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueError,
403 >,
404) -> Error {
405 if let Some(service_err) = err.as_service_error() {
406 let code = service_err.meta().code().unwrap_or("Unknown");
407 let message = service_err.meta().message().unwrap_or("no message");
408 return from_service_error(code, message);
409 }
410 map_generic_error(err)
411}
412
413fn map_describe_error(
416 err: aws_sdk_secretsmanager::error::SdkError<
417 aws_sdk_secretsmanager::operation::describe_secret::DescribeSecretError,
418 >,
419) -> Error {
420 if let Some(service_err) = err.as_service_error() {
421 let code = service_err.meta().code().unwrap_or("Unknown");
422 let message = service_err.meta().message().unwrap_or("no message");
423 return from_service_error(code, message);
424 }
425 map_generic_error(err)
426}
427
428fn map_create_error(
430 err: aws_sdk_secretsmanager::error::SdkError<
431 aws_sdk_secretsmanager::operation::create_secret::CreateSecretError,
432 >,
433) -> Error {
434 if let Some(service_err) = err.as_service_error() {
435 let code = service_err.meta().code().unwrap_or("Unknown");
436 let message = service_err.meta().message().unwrap_or("no message");
437 return from_service_error(code, message);
438 }
439 map_generic_error(err)
440}
441
442fn map_put_error(
444 err: aws_sdk_secretsmanager::error::SdkError<
445 aws_sdk_secretsmanager::operation::put_secret_value::PutSecretValueError,
446 >,
447) -> Error {
448 if let Some(service_err) = err.as_service_error() {
449 let code = service_err.meta().code().unwrap_or("Unknown");
450 let message = service_err.meta().message().unwrap_or("no message");
451 return from_service_error(code, message);
452 }
453 map_generic_error(err)
454}
455
456fn map_list_error(
458 err: aws_sdk_secretsmanager::error::SdkError<
459 aws_sdk_secretsmanager::operation::list_secrets::ListSecretsError,
460 >,
461) -> Error {
462 if let Some(service_err) = err.as_service_error() {
463 let code = service_err.meta().code().unwrap_or("Unknown");
464 let message = service_err.meta().message().unwrap_or("no message");
465 return from_service_error(code, message);
466 }
467 map_generic_error(err)
468}
469
470fn map_delete_error(
472 err: aws_sdk_secretsmanager::error::SdkError<
473 aws_sdk_secretsmanager::operation::delete_secret::DeleteSecretError,
474 >,
475) -> Error {
476 if let Some(service_err) = err.as_service_error() {
477 let code = service_err.meta().code().unwrap_or("Unknown");
478 let message = service_err.meta().message().unwrap_or("no message");
479 return from_service_error(code, message);
480 }
481 map_generic_error(err)
482}
483
484fn from_service_error(code: &str, message: &str) -> Error {
487 match code {
488 "ResourceNotFoundException" => {
489 Error::NotFound(format!("aws-sm:// secret not found: {message}"))
490 }
491 "InvalidParameterException" => {
492 Error::InvalidUrl(format!("aws-sm:// invalid parameter: {message}"))
493 }
494 "InvalidRequestException" | "MalformedPolicyDocumentException" | "EncryptionFailure" => {
495 Error::PreconditionFailed(format!("aws-sm:// request precondition failed: {message}"))
496 }
497 "AccessDeniedException" => {
498 Error::PermissionDenied(format!("aws-sm:// permission denied: {message}"))
499 }
500 "ThrottlingException" => Error::Backend {
501 scheme: "aws-sm",
502 kind: BackendFailureKind::Throttled,
503 message: format!("AWS throttled the request: {message}"),
504 },
505 "DecryptionFailure" | "InternalServiceError" => Error::Backend {
506 scheme: "aws-sm",
507 kind: BackendFailureKind::Transient,
508 message: format!("AWS service error ({code}): {message}"),
509 },
510 _ => Error::Backend {
511 scheme: "aws-sm",
512 kind: BackendFailureKind::Permanent,
513 message: format!("AWS service error ({code}): {message}"),
514 },
515 }
516}
517
518fn map_generic_error<E: std::fmt::Display>(
521 err: aws_sdk_secretsmanager::error::SdkError<E>,
522) -> Error {
523 use aws_sdk_secretsmanager::error::SdkError;
524 match err {
525 SdkError::TimeoutError(_) => Error::Backend {
526 scheme: "aws-sm",
527 kind: BackendFailureKind::Transient,
528 message: "AWS request timed out".into(),
529 },
530 SdkError::DispatchFailure(_) => Error::Backend {
531 scheme: "aws-sm",
532 kind: BackendFailureKind::Transient,
533 message: "AWS request dispatch failed".into(),
534 },
535 _ => Error::Backend {
536 scheme: "aws-sm",
537 kind: BackendFailureKind::Permanent,
538 message: format!("AWS SDK error: {err}"),
539 },
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn parse_valid_url_simple() {
549 let url = Url::parse("aws-sm://us-east-1/my-secret").unwrap();
550 let aws = AwsSmUrl::try_from(&url).unwrap();
551 assert_eq!(aws.region, "us-east-1");
552 assert_eq!(aws.secret_name, "my-secret");
553 assert_eq!(aws.version_stage, None);
554 assert_eq!(aws.version_id, None);
555 }
556
557 #[test]
558 fn parse_valid_url_with_path_secret() {
559 let url = Url::parse("aws-sm://us-west-2/prod/app/db-password").unwrap();
560 let aws = AwsSmUrl::try_from(&url).unwrap();
561 assert_eq!(aws.region, "us-west-2");
562 assert_eq!(aws.secret_name, "prod/app/db-password");
563 }
564
565 #[test]
566 fn parse_valid_url_with_version_stage() {
567 let url = Url::parse("aws-sm://eu-west-1/my-secret?version-stage=AWSPREVIOUS").unwrap();
568 let aws = AwsSmUrl::try_from(&url).unwrap();
569 assert_eq!(aws.version_stage, Some("AWSPREVIOUS".into()));
570 assert_eq!(aws.version_id, None);
571 }
572
573 #[test]
574 fn parse_valid_url_with_version_id() {
575 let url =
576 Url::parse("aws-sm://ap-south-1/my-secret?version-id=abcd-1234-efgh-5678").unwrap();
577 let aws = AwsSmUrl::try_from(&url).unwrap();
578 assert_eq!(aws.version_id, Some("abcd-1234-efgh-5678".into()));
579 assert_eq!(aws.version_stage, None);
580 }
581
582 #[test]
583 fn parse_valid_url_with_field() {
584 let url = Url::parse("aws-sm://us-east-1/my-secret?field=.creds.password").unwrap();
585 let aws = AwsSmUrl::try_from(&url).unwrap();
586 assert_eq!(aws.field, Some(".creds.password".into()));
587 assert_eq!(aws.version_stage, None);
588 assert_eq!(aws.version_id, None);
589 }
590
591 #[test]
592 fn parse_missing_host_fails() {
593 let url = Url::parse("aws-sm:///my-secret").unwrap();
594 assert!(AwsSmUrl::try_from(&url).is_err());
595 }
596
597 #[test]
598 fn parse_empty_path_allowed_for_list() {
599 let url = Url::parse("aws-sm://us-east-1/").unwrap();
600 let aws = AwsSmUrl::try_from(&url).unwrap();
601 assert_eq!(aws.region, "us-east-1");
602 assert_eq!(aws.secret_name, "");
603 }
604
605 #[test]
606 fn empty_secret_name_fails_at_operation() {
607 let backend = AwsSmBackend::new();
608 let url = Url::parse("aws-sm://us-east-1/").unwrap();
609 let dummy = SecretString::new("x".into());
610 assert!(
611 matches!(
612 backend.get(&url),
613 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
614 ),
615 "empty secret name should fail at operation boundary"
616 );
617 assert!(
618 matches!(
619 backend.put(&url, &dummy),
620 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
621 ),
622 "empty secret name should fail at operation boundary for put"
623 );
624 assert!(
625 matches!(
626 backend.delete(&url),
627 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
628 ),
629 "empty secret name should fail at operation boundary for delete"
630 );
631 assert!(
632 matches!(
633 backend.exists(&url),
634 Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
635 ),
636 "empty secret name should fail at operation boundary for exists"
637 );
638 }
639
640 #[test]
641 fn parse_unknown_query_fails() {
642 let url = Url::parse("aws-sm://us-east-1/my-secret?raw=true").unwrap();
643 assert!(AwsSmUrl::try_from(&url).is_err());
644 }
645
646 #[test]
647 fn parse_mutually_exclusive_version_params_fails() {
648 let url = Url::parse(
649 "aws-sm://us-east-1/my-secret?version-stage=AWSCURRENT&version-id=abcd-1234",
650 )
651 .unwrap();
652 assert!(AwsSmUrl::try_from(&url).is_err());
653 }
654
655 #[test]
656 fn error_map_resource_not_found() {
657 let err = from_service_error("ResourceNotFoundException", "secret not found");
658 assert!(matches!(err, Error::NotFound(ref s) if s.contains("secret not found")));
659 }
660
661 #[test]
662 fn error_map_invalid_parameter() {
663 let err = from_service_error("InvalidParameterException", "bad param");
664 assert!(matches!(err, Error::InvalidUrl(ref s) if s.contains("bad param")));
665 }
666
667 #[test]
668 fn error_map_invalid_request() {
669 let err = from_service_error("InvalidRequestException", "bad request");
670 assert!(matches!(err, Error::PreconditionFailed(ref s) if s.contains("bad request")));
671 }
672
673 #[test]
674 fn error_map_access_denied() {
675 let err = from_service_error("AccessDeniedException", "denied");
676 assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("denied")));
677 }
678
679 #[test]
680 fn error_map_throttling() {
681 let err = from_service_error("ThrottlingException", "slow down");
682 assert!(matches!(
683 err,
684 Error::Backend {
685 kind: BackendFailureKind::Throttled,
686 ..
687 }
688 ));
689 }
690
691 #[test]
692 fn error_map_decryption_failure_is_transient() {
693 let err = from_service_error("DecryptionFailure", "kms down");
694 assert!(matches!(
695 err,
696 Error::Backend {
697 kind: BackendFailureKind::Transient,
698 ..
699 }
700 ));
701 }
702
703 #[test]
704 fn error_map_internal_service_error_is_transient() {
705 let err = from_service_error("InternalServiceError", "oops");
706 assert!(matches!(
707 err,
708 Error::Backend {
709 kind: BackendFailureKind::Transient,
710 ..
711 }
712 ));
713 }
714
715 #[test]
716 fn error_map_unknown_code_is_permanent() {
717 let err = from_service_error("SomeWeirdException", "unknown");
718 assert!(matches!(
719 err,
720 Error::Backend {
721 kind: BackendFailureKind::Permanent,
722 ..
723 }
724 ));
725 }
726
727 #[test]
728 fn error_map_encryption_failure_is_precondition_failed() {
729 let err = from_service_error("EncryptionFailure", "kms failure");
730 assert!(matches!(err, Error::PreconditionFailed(ref s) if s.contains("kms failure")));
731 }
732
733 #[test]
734 fn supported_operations() {
735 let _backend = AwsSmBackend::new();
736 }
739
740 #[test]
741 fn backend_new_ok() {
742 let _backend = AwsSmBackend::new();
743 }
744
745 #[test]
746 fn backend_scheme() {
747 let backend = AwsSmBackend::new();
748 assert_eq!(backend.scheme(), "aws-sm");
749 }
750
751 #[test]
757 fn field_extraction_happy() {
758 let payload = r#"{"username":"app","password":"hunter2"}"#;
761 let v = hasp_core::extract_field_from_str(payload, "password").unwrap();
762 assert_eq!(v, "hunter2");
763 }
764
765 #[test]
766 fn field_extraction_missing_field_is_not_found() {
767 let payload = r#"{"username":"app"}"#;
768 let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
769 assert!(matches!(err, Error::NotFound(_)));
770 }
771
772 #[test]
773 fn field_extraction_non_json_is_invalid_url() {
774 let payload = "not-a-json-secret";
775 let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
776 assert!(matches!(err, Error::InvalidUrl(_)));
777 }
778}