Skip to main content

hasp_backend_aws_sm/
lib.rs

1//! `aws-sm://` backend for hasp.
2//!
3//! Grammar: `aws-sm://<region>/<secret-name>?version-stage=<stage>&version-id=<id>&field=<path>`
4//!   - `<region>`       — AWS region (host). Must be non-empty.
5//!   - `<secret-name>`  — Secret name or ARN (path). Leading `/` is stripped.
6//!   - `?version-stage` — Optional version stage (e.g. `AWSCURRENT`,
7//!     `AWSPREVIOUS`). Mutually exclusive with `version-id`.
8//!   - `?version-id`    — Optional version UUID. Mutually exclusive with
9//!     `version-stage`.
10//!   - `?field`         — Optional dotted JSON path. When set, the stored
11//!     secret value is parsed as JSON and the named scalar is returned
12//!     (see `hasp_core::extract_field`). Non-JSON payloads fail with
13//!     `InvalidUrl`.
14//!
15//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
16//!
17//! Authentication is ambient only: `AWS_ACCESS_KEY_ID` +
18//! `AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, IAM role via IMDS/ECS/EKS, or
19//! any other source supported by the AWS default credential chain. No
20//! auth-bootstrap flows or credential refresh logic lives in this crate.
21//!
22//! AWS Secrets Manager can store text or binary values. Only text secrets
23//! are supported by this backend; binary secrets return a permanent backend
24//! error because the hasp `Backend` contract is text-oriented.
25//!
26//! Region is required in the URL so the same secret name can be addressed
27//! across partitions, and so the URL is self-contained (no ambient region
28//! dependency).
29
30use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
31use url::Url;
32
33/// URL shape for `aws-sm://` addresses.
34///
35/// Region and secret name are identifiers, not secret values. They may
36/// appear in error messages (redacted per URL discipline).
37#[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/// AWS Secrets Manager SDK backend.
100///
101/// Construction attempts to build a Tokio runtime so the async AWS SDK
102/// can be used from the sync `Backend` trait. The runtime is
103/// `current_thread` to keep the backend lightweight for short-lived CLI
104/// invocations. If runtime creation fails, the error is stored and
105/// replayed on the first operation.
106///
107/// **Note:** Explicit proxy configuration is not yet supported for AWS SDK
108/// backends. Use the `HTTPS_PROXY`/`HTTP_PROXY` environment variables instead.
109#[derive(Debug)]
110pub struct AwsSmBackend {
111    init: Result<tokio::runtime::Runtime, Error>,
112}
113
114impl AwsSmBackend {
115    /// Create a new `AwsSmBackend`.
116    ///
117    /// Errors on construction are deferred to first use so
118    /// `Store::with_defaults()` never panics.
119    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    /// Create a new `AwsSmBackend` with an explicit proxy.
134    ///
135    /// **Note:** Explicit proxy configuration is not yet supported for
136    /// AWS SDK backends. Use the `HTTPS_PROXY`/`HTTP_PROXY` environment
137    /// variables instead.
138    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
220/// Build an AWS SDK config scoped to the given region.
221async 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
228/// Fetch a secret value via `GetSecretValue`.
229///
230/// Binary secrets are rejected because the hasp contract is text-only.
231async 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    // Field extraction runs on the parsed JSON before wrapping in
266    // `SecretString` — the parent payload never escapes this function
267    // as a plaintext `String`.
268    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
275/// Probe secret existence via `DescribeSecret`.
276///
277/// `DescribeSecret` is metadata-only; no secret value crosses the wire.
278async 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
292/// Create or update a secret value via `CreateSecret` / `PutSecretValue`.
293///
294/// Tries `CreateSecret` first; on `AlreadyExistsException`, falls back to
295/// `PutSecretValue` to update the existing secret.
296async 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
331/// List secrets via `ListSecrets`.
332///
333/// Returns every secret in the region as an `Entry`. Transparent pagination
334/// follows `NextToken` until exhausted (bounded at 500 pages).
335async 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
379/// Delete a secret via `DeleteSecret` with soft-delete (recovery window).
380///
381/// `ForceDeleteWithoutRecovery` is `false` so AWS retains the secret for the
382/// service-managed recovery period.
383async 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
398/// Map a `GetSecretValue` SDK error into the locked `hasp_core::Error`
399/// taxonomy.
400fn 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
413/// Map a `DescribeSecret` SDK error into the locked `hasp_core::Error`
414/// taxonomy.
415fn 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
428/// Map a `CreateSecret` SDK error into the locked `hasp_core::Error` taxonomy.
429fn 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
442/// Map a `PutSecretValue` SDK error into the locked `hasp_core::Error` taxonomy.
443fn 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
456/// Map a `ListSecrets` SDK error into the locked `hasp_core::Error` taxonomy.
457fn 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
470/// Map a `DeleteSecret` SDK error into the locked `hasp_core::Error` taxonomy.
471fn 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
484/// Convert AWS service error metadata into a stable `hasp_core::Error`.
485// TODO(#4): validate against live AWS account — see notes/TODO-live-error-mapping.md
486fn 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
518/// Map non-service SDK errors (timeouts, dispatch failures, construction
519/// failures) into the locked `hasp_core::Error` taxonomy.
520fn 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        // put, list, delete are now implemented; they fail at network layer
737        // because no AWS credentials are configured in unit tests.
738    }
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    // Confirms the get-path extraction integration: a typical AWS
752    // Secrets Manager JSON payload + ?field= path produces the same
753    // result as the shared helper. Catches regressions where a
754    // backend's get() forgets to call extract_field_from_str (e.g.,
755    // future refactors that bypass the field-extraction step).
756    #[test]
757    fn field_extraction_happy() {
758        // Typical aws-sm payload shape: a JSON object stored as the
759        // secret string.
760        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}