use std::collections::BTreeMap;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::Debug;
use aws_sdk_secretsmanager::Client;
use aws_sdk_secretsmanager::error::ProvideErrorMetadata;
use aws_sdk_secretsmanager::error::SdkError;
use aws_sdk_secretsmanager::primitives::Blob;
use aws_sdk_secretsmanager::types::Tag;
use secrecy::ExposeSecret;
use serde::Deserialize;
use serde::Serialize;
use super::Address;
use super::Provider;
use super::ProviderUrl;
use super::join_slash_path;
use crate::MonosecretError;
use crate::ProviderValue;
use crate::Result;
use crate::SecretBytes;
const AWS_BATCH_GET_MAX_SECRETS: usize = 20;
fn format_aws_error<E, R>(error: &SdkError<E, R>) -> String
where
E: Error + ProvideErrorMetadata + 'static,
R: Debug + 'static,
{
if let Some(service_error) = error.as_service_error() {
return match (service_error.code(), service_error.message()) {
(Some(code), Some(message)) => format!("{code}: {message}"),
(Some(code), None) => code.to_string(),
(None, Some(message)) => message.to_string(),
(None, None) => crate::error::display_error_chain(service_error),
};
}
crate::error::display_error_chain(error)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AwssmConfig {
pub region: Option<String>,
pub aws_profile: Option<String>,
pub prefix: Option<String>,
pub kms_key_id: Option<String>,
#[serde(default)]
pub tags: BTreeMap<String, String>,
}
fn canonicalize_prefix(prefix: &str) -> String {
match prefix.trim_end_matches('/') {
"" => "/".to_string(),
prefix => prefix.to_string(),
}
}
impl TryFrom<&ProviderUrl> for AwssmConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "awssm" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for awssm provider. Expected 'awssm'.",
url.scheme()
)));
}
let aws_profile = {
let username = url.username();
if username.is_empty() {
None
} else {
Some(username)
}
};
let region = url.host().filter(|s| !s.is_empty());
let prefix = url
.query_value("prefix")
.map(|prefix| canonicalize_prefix(&prefix));
let kms_key_id = url.query_value("kms_key_id");
let tags: BTreeMap<String, String> = url
.query_pairs()
.filter_map(|(k, v)| {
k.strip_prefix("tag.")
.filter(|name| !name.is_empty())
.map(|name| (name.to_string(), v.into_owned()))
})
.collect();
let name = url.path().trim_start_matches('/').to_string();
if !name.is_empty() {
let hint = crate::config::ref_table_hint(None, &name, None, None);
return Err(MonosecretError::ProviderOperationFailed(format!(
"awssm URIs take no path: address the secret with \
{hint} on the secret instead \
(add field = \"<json-key>\" to extract one JSON key)"
)));
}
Ok(Self {
region,
aws_profile,
prefix,
kms_key_id,
tags,
})
}
}
pub struct AwssmProvider {
config: AwssmConfig,
}
crate::register_provider! {
struct: AwssmProvider,
config: AwssmConfig,
metadata: &super::catalog::AWSSM,
}
impl AwssmProvider {
fn secret_value(binary: Option<&[u8]>, text: Option<&str>) -> Option<SecretBytes> {
binary
.map(SecretBytes::from_slice)
.or_else(|| text.map(SecretBytes::from_utf8))
}
pub fn new(config: AwssmConfig) -> Self {
Self { config }
}
fn format_secret_name(
prefix: Option<&str>,
project: &str,
profile: &str,
key: &str,
) -> Result<String> {
if project.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"project cannot be empty".to_string(),
));
}
if profile.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"profile cannot be empty".to_string(),
));
}
if key.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"key cannot be empty".to_string(),
));
}
let convention_name = format!("monosecret/{project}/{profile}/{key}");
let secret_name = match prefix {
Some(prefix) => join_slash_path(prefix, &convention_name),
None => convention_name,
};
if secret_name.len() > 512 {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Secret name too long: {} characters (max 512)",
secret_name.len()
)));
}
Ok(secret_name)
}
async fn create_client(&self) -> Result<Client> {
let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(region) = &self.config.region {
config_loader = config_loader.region(aws_config::Region::new(region.clone()));
}
if let Some(profile) = &self.config.aws_profile {
config_loader = config_loader.profile_name(profile);
}
let sdk_config = config_loader.load().await;
Ok(Client::new(&sdk_config))
}
fn extract_json_key(name: &str, value: &str, json_key: &str) -> Result<Option<SecretBytes>> {
let json: serde_json::Value = serde_json::from_str(value).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"secret '{name}' is not JSON, cannot extract key '{json_key}': {e}"
))
})?;
Ok(json
.get(json_key)
.and_then(crate::json_field::render_field)
.map(|value| SecretBytes::from_utf8(value.expose_secret())))
}
fn versioned_value(
value: SecretBytes,
arn: Option<&str>,
version: Option<&str>,
) -> ProviderValue {
let revision = arn
.filter(|v| !v.is_empty())
.zip(version.filter(|v| !v.is_empty()))
.map(|(arn, version)| crate::revision::digest("monosecret.awssm.v1", &[arn, version]));
ProviderValue::new(value, None).with_revision(revision)
}
fn select_value(
name: &str,
value: ProviderValue,
field: Option<&str>,
) -> Result<Option<ProviderValue>> {
let selected = match field {
None => Some(value.value),
Some(field) => Self::extract_json_key(name, value.value.try_as_utf8_for(name)?, field)?,
};
let revision = value.revision.map(|revision| {
crate::revision::digest(
"monosecret.awssm.selection.v1",
&[
revision.as_str(),
if field.is_some() { "field" } else { "whole" },
field.unwrap_or(""),
],
)
});
Ok(selected.map(|value| ProviderValue::new(value, None).with_revision(revision)))
}
async fn get_coords_async(
&self,
name: &str,
json_key: Option<&str>,
) -> Result<Option<ProviderValue>> {
let client = self.create_client().await?;
let output = match client.get_secret_value().secret_id(name).send().await {
Ok(output) => output,
Err(err) => {
return if err
.as_service_error()
.is_some_and(aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueError::is_resource_not_found_exception)
{
Ok(None)
} else {
Err(MonosecretError::ProviderOperationFailed(format!(
"Failed to get secret '{}': {}",
name,
format_aws_error(&err)
)))
};
}
};
Self::single_result(name, json_key, &output)
}
fn single_result(
name: &str,
json_key: Option<&str>,
output: &aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueOutput,
) -> Result<Option<ProviderValue>> {
let value = Self::secret_value(
output.secret_binary().map(AsRef::as_ref),
output.secret_string(),
);
let Some(value) = value else { return Ok(None) };
Self::select_value(
name,
Self::versioned_value(value, output.arn(), output.version_id()),
json_key,
)
}
async fn get_many_async(
&self,
resolved: &[(&str, crate::config::NativeAddress)],
) -> Result<HashMap<String, ProviderValue>> {
let client = self.create_client().await?;
let mut unique: Vec<&str> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (_, coords) in resolved {
if seen.insert(coords.item.as_str()) {
unique.push(coords.item.as_str());
}
}
let mut fetched: HashMap<String, ProviderValue> = HashMap::new();
for chunk in unique.chunks(AWS_BATCH_GET_MAX_SECRETS) {
let mut request = client.batch_get_secret_value();
for name in chunk {
request = request.secret_id_list(*name);
}
let response = request.send().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"BatchGetSecretValue failed: {}",
format_aws_error(&e)
))
})?;
Self::index_batch_values(&mut fetched, response.secret_values());
for error in response.errors() {
let error_code = error.error_code().unwrap_or("Unknown");
if error_code != "ResourceNotFoundException" {
let secret_id = error.secret_id().unwrap_or("unknown");
let message = error.message().unwrap_or("no message");
return Err(MonosecretError::ProviderOperationFailed(format!(
"Failed to get secret '{secret_id}': {error_code} - {message}"
)));
}
}
}
let mut results = HashMap::new();
for (name, coords) in resolved {
let Some(value) = fetched.get(coords.item.as_str()) else {
continue;
};
let secret = Self::select_value(&coords.item, value.clone(), coords.field.as_deref())?;
if let Some(secret) = secret {
results.insert((*name).to_string(), secret);
}
}
Ok(results)
}
fn index_batch_values(
fetched: &mut HashMap<String, ProviderValue>,
secrets: &[aws_sdk_secretsmanager::types::SecretValueEntry],
) {
for secret in secrets {
if let Some(value) = Self::secret_value(
secret.secret_binary().map(AsRef::as_ref),
secret.secret_string(),
) {
let value = Self::versioned_value(value, secret.arn(), secret.version_id());
if let Some(name) = secret.name() {
fetched.insert(name.to_string(), value.clone());
}
if let Some(arn) = secret.arn() {
fetched.insert(arn.to_string(), value);
}
}
}
}
async fn set_secret_async(&self, secret_name: &str, value: &SecretBytes) -> Result<()> {
let client = self.create_client().await?;
let mut create = client.create_secret().name(secret_name);
create = match value.try_as_utf8() {
Ok(text) => create.secret_string(text),
Err(_) => create.secret_binary(Blob::new(value.to_vec())),
};
if let Some(kms_key_id) = &self.config.kms_key_id {
create = create.kms_key_id(kms_key_id);
}
for (key, val) in &self.config.tags {
create = create.tags(Tag::builder().key(key).value(val).build());
}
match create.send().await {
Ok(_) => Ok(()),
Err(err) => {
if err
.as_service_error()
.is_some_and(aws_sdk_secretsmanager::operation::create_secret::CreateSecretError::is_resource_exists_exception)
{
let update = client.put_secret_value().secret_id(secret_name);
let update = match value.try_as_utf8() {
Ok(text) => update.secret_string(text),
Err(_) => update.secret_binary(Blob::new(value.to_vec())),
};
update.send().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to update secret '{}': {}",
secret_name,
format_aws_error(&e)
))
})?;
Ok(())
} else {
Err(MonosecretError::ProviderOperationFailed(format!(
"Failed to create secret '{}': {}",
secret_name,
format_aws_error(&err)
)))
}
}
}
}
}
impl Provider for AwssmProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: Self::format_secret_name(self.config.prefix.as_deref(), project, profile, key)?,
..Default::default()
})
}
fn name(&self) -> &str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let base = match (&self.config.aws_profile, &self.config.region) {
(Some(profile), Some(region)) => format!("awssm://{profile}@{region}"),
(None, Some(region)) => format!("awssm://{region}"),
(_, None) => "awssm".to_string(),
};
let mut params: Vec<String> = Vec::new();
if let Some(prefix) = &self.config.prefix {
params.push(format!(
"prefix={}",
ProviderUrl::encode_query(&canonicalize_prefix(prefix))
));
}
if let Some(kms_key_id) = &self.config.kms_key_id {
params.push(format!(
"kms_key_id={}",
ProviderUrl::encode_query(kms_key_id)
));
}
for (key, value) in &self.config.tags {
params.push(format!(
"tag.{}={}",
ProviderUrl::encode_query(key),
ProviderUrl::encode_query(value)
));
}
if params.is_empty() {
base
} else {
let sep = if self.config.region.is_some() {
"?"
} else {
"://?"
};
format!("{}{}{}", base, sep, params.join("&"))
}
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field"]
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretBytes>> {
self.get_with_metadata(addr)
.map(|value| value.map(|value| value.value))
}
fn get_with_metadata(&self, addr: Address<'_>) -> Result<Option<ProviderValue>> {
let coords = self.resolve_coords(addr)?;
super::block_on(self.get_coords_async(&coords.item, coords.field.as_deref()))
}
fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> {
self.check_writable(addr)?;
let coords = self.resolve_coords(addr)?;
super::block_on(self.set_secret_async(&coords.item, value))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Convention { .. } => Ok(()),
Address::Native(_) => {
Err(MonosecretError::ProviderOperationFailed(
"awssm secret references are read-only and cannot be written".to_string(),
))
}
}
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretBytes>> {
self.get_many_with_metadata(requests).map(|values| {
values
.into_iter()
.map(|(name, value)| (name, value.value))
.collect()
})
}
fn get_many_with_metadata(
&self,
requests: &[(&str, Address<'_>)],
) -> Result<HashMap<String, ProviderValue>> {
if requests.is_empty() {
return Ok(HashMap::new());
}
let mut resolved = Vec::with_capacity(requests.len());
for (name, addr) in requests {
resolved.push((*name, self.resolve_coords(*addr)?.into_owned()));
}
super::block_on(self.get_many_async(&resolved))
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)] mod tests {
use aws_sdk_secretsmanager::operation::batch_get_secret_value::BatchGetSecretValueError;
use super::*;
#[test]
fn revision_single_and_batch_responses_agree_for_names_arns_and_fields() {
use aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueOutput;
use aws_sdk_secretsmanager::types::SecretValueEntry;
let arn = "arn:aws:secretsmanager:us-east-1:123:secret:db-abcdef";
let single = GetSecretValueOutput::builder()
.arn(arn)
.name("db")
.version_id("version-1")
.secret_string(r#"{"password":"one","user":"alice"}"#)
.build();
let batch = SecretValueEntry::builder()
.arn(arn)
.name("db")
.version_id("version-1")
.secret_string(r#"{"password":"one","user":"alice"}"#)
.build();
let mut indexed = HashMap::new();
AwssmProvider::index_batch_values(&mut indexed, &[batch]);
for name in ["db", arn] {
for field in [None, Some("password"), Some("user")] {
let single = AwssmProvider::single_result(name, field, &single)
.unwrap()
.unwrap();
let batch = AwssmProvider::select_value(name, indexed[name].clone(), field)
.unwrap()
.unwrap();
assert!(single.revision.is_some());
assert_eq!(single.revision, batch.revision);
assert_eq!(single.value.expose_secret(), batch.value.expose_secret());
}
}
}
#[test]
fn revision_identity_generation_and_field_selection() {
let select = |arn, version, field, bytes: &str| {
AwssmProvider::select_value(
"alias",
AwssmProvider::versioned_value(SecretBytes::from_utf8(bytes), arn, version),
field,
)
.unwrap()
.unwrap()
};
let arn = Some("arn:aws:secretsmanager:us-east-1:123:secret:db-abcdef");
let first = select(
arn,
Some("generation-1"),
Some("password"),
r#"{"password":"one","user":"alice"}"#,
);
let repeated = select(
arn,
Some("generation-1"),
Some("password"),
r#"{"password":"one","user":"alice"}"#,
);
assert_eq!(first.revision, repeated.revision);
assert_eq!(first.value.expose_secret(), b"one");
let rotated = select(
arn,
Some("generation-2"),
Some("password"),
r#"{"password":"one"}"#,
);
assert_ne!(first.revision, rotated.revision);
let recreated = select(
Some("arn:aws:secretsmanager:us-east-1:123:secret:db-ghijkl"),
Some("generation-1"),
Some("password"),
r#"{"password":"two"}"#,
);
assert_ne!(first.revision, recreated.revision);
let other_field = select(
arn,
Some("generation-1"),
Some("user"),
r#"{"password":"one","user":"alice"}"#,
);
assert_ne!(first.revision, other_field.revision);
let whole = select(arn, Some("generation-1"), None, r#"{"":"empty-key"}"#);
let empty_key = select(arn, Some("generation-1"), Some(""), r#"{"":"empty-key"}"#);
assert_ne!(whole.revision, empty_key.revision);
assert!(
select(None, Some("generation-1"), None, "one")
.revision
.is_none()
);
assert!(select(arn, None, None, "one").revision.is_none());
let binary = AwssmProvider::select_value(
"binary",
AwssmProvider::versioned_value(
SecretBytes::from_vec(vec![0, 255]),
arn,
Some("binary-version"),
),
None,
)
.unwrap()
.unwrap();
assert_eq!(binary.value.expose_secret(), &[0, 255]);
assert!(binary.revision.is_some());
}
#[test]
fn test_format_secret_name() {
let name = AwssmProvider::format_secret_name(None, "myapp", "prod", "DB_URL").unwrap();
assert_eq!(name, "monosecret/myapp/prod/DB_URL");
}
#[test]
fn test_format_secret_name_with_prefix() {
let name =
AwssmProvider::format_secret_name(Some("myteam"), "myapp", "prod", "DB_URL").unwrap();
assert_eq!(name, "myteam/monosecret/myapp/prod/DB_URL");
}
#[test]
fn test_format_secret_name_with_nested_prefix() {
let name =
AwssmProvider::format_secret_name(Some("org/team"), "myapp", "prod", "DB_URL").unwrap();
assert_eq!(name, "org/team/monosecret/myapp/prod/DB_URL");
}
#[test]
fn test_format_secret_name_normalizes_prefix_boundary() {
for (prefix, expected) in [
("myteam", "myteam/monosecret/myapp/prod/DB_URL"),
("myteam/", "myteam/monosecret/myapp/prod/DB_URL"),
("myteam///", "myteam/monosecret/myapp/prod/DB_URL"),
("/myteam/", "/myteam/monosecret/myapp/prod/DB_URL"),
("/", "/monosecret/myapp/prod/DB_URL"),
] {
let name =
AwssmProvider::format_secret_name(Some(prefix), "myapp", "prod", "DB_URL").unwrap();
assert_eq!(name, expected, "prefix {prefix:?}");
}
}
#[test]
fn test_format_secret_name_too_long() {
let long_key = "A".repeat(500);
let result = AwssmProvider::format_secret_name(None, "myapp", "prod", &long_key);
assert!(result.is_err());
}
#[test]
fn test_format_secret_name_empty_inputs() {
assert!(AwssmProvider::format_secret_name(None, "", "prod", "KEY").is_err());
assert!(AwssmProvider::format_secret_name(None, "proj", "", "KEY").is_err());
assert!(AwssmProvider::format_secret_name(None, "proj", "prod", "").is_err());
}
#[test]
fn test_convention_address() {
let p = AwssmProvider::new(config("awssm://us-east-1"));
let coords = p.convention_address("proj", "default", "A").unwrap();
assert_eq!(coords.item, "monosecret/proj/default/A");
assert_eq!(coords.field, None);
}
#[test]
fn test_convention_address_with_prefix() {
let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam"));
let coords = p.convention_address("proj", "default", "A").unwrap();
assert_eq!(coords.item, "myteam/monosecret/proj/default/A");
}
#[test]
fn test_convention_address_with_trailing_slash_prefix() {
let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam/"));
let coords = p.convention_address("proj", "default", "A").unwrap();
assert_eq!(coords.item, "myteam/monosecret/proj/default/A");
}
#[test]
fn trailing_slash_prefix_has_canonical_storage_identity() {
let canonical = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam"));
let trailing_slash = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam/"));
assert_eq!(trailing_slash.config.prefix.as_deref(), Some("myteam"));
assert_eq!(trailing_slash.uri(), canonical.uri());
assert!(crate::provider::same_storage_container(
&trailing_slash,
&canonical
));
}
#[test]
fn parses_kms_key_id_and_tags() {
let c =
config("awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.env=prod&tag.team=platform");
assert_eq!(c.kms_key_id.as_deref(), Some("alias/my-key"));
assert_eq!(c.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(c.tags.get("team").map(String::as_str), Some("platform"));
}
#[test]
fn absent_kms_and_tags_default_to_empty() {
let c = config("awssm://us-east-1");
assert_eq!(c.kms_key_id, None);
assert!(c.tags.is_empty());
}
#[test]
fn empty_tag_value_is_kept() {
let c = config("awssm://us-east-1?tag.env=");
assert_eq!(c.tags.get("env").map(String::as_str), Some(""));
}
#[test]
fn uri_round_trips_kms_and_sorted_tags() {
let c = config("awssm://prod@us-east-1?tag.team=platform&kms_key_id=alias/k&tag.env=prod");
let p = AwssmProvider::new(c);
assert_eq!(
p.uri(),
"awssm://prod@us-east-1?kms_key_id=alias/k&tag.env=prod&tag.team=platform"
);
let reparsed = config(&p.uri());
assert_eq!(reparsed.kms_key_id.as_deref(), Some("alias/k"));
assert_eq!(reparsed.tags.get("env").map(String::as_str), Some("prod"));
assert_eq!(
reparsed.tags.get("team").map(String::as_str),
Some("platform")
);
}
#[test]
fn uri_round_trips_prefix_with_kms_and_tags() {
let p = AwssmProvider::new(config(
"awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod",
));
assert_eq!(
p.uri(),
"awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod"
);
}
#[test]
fn test_batch_chunking() {
let names: Vec<String> = (0..45).map(|i| format!("SECRET_{i}")).collect();
let chunks: Vec<&[String]> = names.chunks(AWS_BATCH_GET_MAX_SECRETS).collect();
assert_eq!(chunks.len(), 3); assert_eq!(chunks[0].len(), 20);
assert_eq!(chunks[1].len(), 20);
assert_eq!(chunks[2].len(), 5);
}
#[test]
fn batch_error_includes_unmodeled_service_code_and_message() {
let service_error = BatchGetSecretValueError::generic(
aws_sdk_secretsmanager::error::ErrorMetadata::builder()
.code("AccessDeniedException")
.message("not authorized to read these secrets")
.build(),
);
let sdk_error = SdkError::service_error(service_error, ());
assert_eq!(
format_aws_error(&sdk_error),
"AccessDeniedException: not authorized to read these secrets"
);
}
#[test]
fn batch_error_includes_non_service_cause() {
let sdk_error: SdkError<BatchGetSecretValueError, ()> =
SdkError::construction_failure(std::io::Error::other("invalid AWS endpoint"));
assert_eq!(
format_aws_error(&sdk_error),
"failed to construct request: invalid AWS endpoint"
);
}
fn config(s: &str) -> AwssmConfig {
use url::Url;
AwssmConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap()
}
#[test]
fn native_address_is_read_only() {
let p = AwssmProvider::new(config("awssm://us-east-1"));
let addr = crate::config::NativeAddress {
item: "prod/db-credentials".into(),
field: Some("password".into()),
..Default::default()
};
let refusal = p.check_writable(Address::Native(&addr)).unwrap_err();
assert!(refusal.to_string().contains("read-only"), "{refusal}");
let err = p
.set(Address::Native(&addr), &SecretBytes::from_utf8("v"))
.unwrap_err();
assert_eq!(err.to_string(), refusal.to_string());
}
#[test]
fn native_address_rejects_section() {
let p = AwssmProvider::new(config("awssm://us-east-1"));
let addr = crate::config::NativeAddress {
item: "prod/db-credentials".into(),
section: Some("x".into()),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`section`"), "{err}");
}
#[test]
fn extract_json_key_returns_string_value() {
let value = r#"{"username": "admin", "password": "s3cret"}"#;
let got = AwssmProvider::extract_json_key("db", value, "password").unwrap();
assert_eq!(got.unwrap().expose_secret(), b"s3cret");
}
#[test]
fn extract_json_key_renders_non_string_values_verbatim() {
let value = r#"{"port": 5432, "tls": true}"#;
assert_eq!(
AwssmProvider::extract_json_key("db", value, "port")
.unwrap()
.unwrap()
.expose_secret(),
b"5432"
);
assert_eq!(
AwssmProvider::extract_json_key("db", value, "tls")
.unwrap()
.unwrap()
.expose_secret(),
b"true"
);
}
#[test]
fn extract_json_key_null_is_none_not_the_string_null() {
let value = r#"{"password": null}"#;
assert!(
AwssmProvider::extract_json_key("db", value, "password")
.unwrap()
.is_none()
);
}
#[test]
fn extract_json_key_null_matches_a_missing_key() {
let with_null = r#"{"password": null}"#;
let without = r#"{"username": "admin"}"#;
assert_eq!(
AwssmProvider::extract_json_key("db", with_null, "password")
.unwrap()
.is_none(),
AwssmProvider::extract_json_key("db", without, "password")
.unwrap()
.is_none()
);
}
#[test]
fn extract_json_key_missing_key_is_none() {
let value = r#"{"username": "admin"}"#;
assert!(
AwssmProvider::extract_json_key("db", value, "password")
.unwrap()
.is_none()
);
}
#[test]
fn extract_json_key_on_non_json_value_errors() {
let err = AwssmProvider::extract_json_key("db", "plain-string", "password").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("is not JSON"), "{msg}");
assert!(msg.contains("db") && msg.contains("password"), "{msg}");
}
}