#![allow(clippy::result_large_err)]
use super::config::{AwsConfig, AwsCredentials};
use super::error::AwsError;
use super::sigv4::sign;
#[derive(Debug, PartialEq, Eq)]
pub enum PushOutcome {
Created,
Updated,
Unchanged,
Deleted,
}
fn endpoint_host(endpoint: &str) -> &str {
endpoint
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(endpoint)
.split('/')
.next()
.unwrap_or(endpoint)
}
const MAX_RETRIES: u32 = 3;
const DEFAULT_RETRY_SECS: u64 = 2;
fn http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
}
fn call_with_retry(
make_request: impl Fn() -> Result<ureq::Response, ureq::Error>,
) -> Result<ureq::Response, ureq::Error> {
for attempt in 0..=MAX_RETRIES {
match make_request() {
Ok(resp) => return Ok(resp),
Err(ureq::Error::Status(429, resp)) if attempt < MAX_RETRIES => {
let retry_after = resp
.header("Retry-After")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_RETRY_SECS * 2u64.pow(attempt));
let wait = std::cmp::min(retry_after, 30); std::thread::sleep(std::time::Duration::from_secs(wait));
}
Err(e) => return Err(e),
}
}
let body = format!(
"secrets manager retry loop exhausted after {} attempts",
MAX_RETRIES + 1
);
let resp = ureq::Response::new(504, "Retries Exhausted", &body)
.expect("static 504 response must build");
Err(ureq::Error::Status(504, resp))
}
fn map_ureq_error(e: ureq::Error, secret_name: Option<&str>) -> AwsError {
match e {
ureq::Error::Status(400, resp) => {
let body = resp.into_string().unwrap_or_default();
if body.contains("ResourceNotFoundException") {
AwsError::NotFound(secret_name.unwrap_or("").to_string())
} else {
AwsError::Http {
status: 400,
message: body,
}
}
}
ureq::Error::Status(s, resp) => AwsError::Http {
status: s,
message: resp
.into_string()
.unwrap_or_else(|_| "<unreadable response>".into()),
},
other => AwsError::Transport(other.to_string()),
}
}
pub fn normalize_name(name: &str) -> String {
name.replace(['/', '-'], "_").to_uppercase()
}
pub fn pull_secrets(
cfg: &AwsConfig,
get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
prefix: Option<&str>,
) -> Result<Vec<(String, String)>, AwsError> {
let names = list_secret_names(cfg, get_creds, prefix)?;
let creds = get_creds()?;
let mut secrets = Vec::new();
for name in &names {
let value = get_secret_value(cfg, &creds, name)?;
let key = normalize_name(name);
secrets.push((key, value));
}
Ok(secrets)
}
fn list_secret_names(
cfg: &AwsConfig,
get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
prefix: Option<&str>,
) -> Result<Vec<String>, AwsError> {
const TARGET: &str = "secretsmanager.ListSecrets";
let agent = http_agent();
let mut names = Vec::new();
let mut next_token: Option<String> = None;
loop {
let creds = get_creds()?;
let body = match (&next_token, prefix) {
(Some(tok), Some(p)) => serde_json::json!({
"MaxResults": 100,
"Filters": [{"Key": "name", "Values": [p]}],
"NextToken": tok,
}),
(Some(tok), None) => serde_json::json!({
"MaxResults": 100,
"NextToken": tok,
}),
(None, Some(p)) => serde_json::json!({
"MaxResults": 100,
"Filters": [{"Key": "name", "Values": [p]}],
}),
(None, None) => serde_json::json!({ "MaxResults": 100 }),
};
let body_str = body.to_string();
let sig = sign(
&cfg.region,
endpoint_host(&cfg.endpoint),
TARGET,
&body_str,
&creds.access_key_id,
&creds.secret_access_key,
creds.session_token.as_deref(),
);
let body_clone = body_str.clone();
let endpoint = cfg.endpoint.clone();
let resp: serde_json::Value = call_with_retry(|| {
let mut req = agent
.post(&endpoint)
.set("Content-Type", "application/x-amz-json-1.1")
.set("X-Amz-Target", TARGET)
.set("X-Amz-Date", &sig.x_amz_date)
.set("Authorization", &sig.authorization);
if let Some(ref tok) = sig.x_amz_security_token {
req = req.set("X-Amz-Security-Token", tok);
}
req.send_string(&body_clone)
})
.map_err(|e| map_ureq_error(e, None))?
.into_json()
.map_err(|e| AwsError::Transport(e.to_string()))?;
let list = resp["SecretList"].as_array().ok_or_else(|| {
AwsError::Transport("Secrets Manager response missing 'SecretList' array".into())
})?;
for item in list {
if let Some(name) = item["Name"].as_str() {
if !name.is_empty() {
names.push(name.to_string());
}
}
}
next_token = resp["NextToken"].as_str().map(|s| s.to_string());
if next_token.is_none() {
break;
}
}
Ok(names)
}
fn get_secret_value(
cfg: &AwsConfig,
creds: &AwsCredentials,
name: &str,
) -> Result<String, AwsError> {
const TARGET: &str = "secretsmanager.GetSecretValue";
let agent = http_agent();
let body = serde_json::json!({ "SecretId": name }).to_string();
let sig = sign(
&cfg.region,
endpoint_host(&cfg.endpoint),
TARGET,
&body,
&creds.access_key_id,
&creds.secret_access_key,
creds.session_token.as_deref(),
);
let body_clone = body.clone();
let endpoint = cfg.endpoint.clone();
let sig_date = sig.x_amz_date.clone();
let sig_auth = sig.authorization.clone();
let sig_tok = sig.x_amz_security_token.clone();
let resp: serde_json::Value = call_with_retry(|| {
let mut req = agent
.post(&endpoint)
.set("Content-Type", "application/x-amz-json-1.1")
.set("X-Amz-Target", TARGET)
.set("X-Amz-Date", &sig_date)
.set("Authorization", &sig_auth);
if let Some(ref tok) = sig_tok {
req = req.set("X-Amz-Security-Token", tok);
}
req.send_string(&body_clone)
})
.map_err(|e| map_ureq_error(e, Some(name)))?
.into_json()
.map_err(|e| AwsError::Transport(e.to_string()))?;
resp["SecretString"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| AwsError::NotFound(name.to_string()))
}
#[tracing::instrument(skip(cfg, get_creds, value), fields(name = %name))]
pub fn push_secret(
cfg: &AwsConfig,
get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
name: &str,
value: &str,
) -> Result<PushOutcome, AwsError> {
let creds = get_creds()?;
match get_secret_value(cfg, &creds, name) {
Ok(existing_value) => {
if existing_value == value {
return Ok(PushOutcome::Unchanged);
}
put_secret_value(cfg, &creds, name, value)?;
Ok(PushOutcome::Updated)
}
Err(AwsError::NotFound(_)) => {
create_secret(cfg, &creds, name, value)?;
Ok(PushOutcome::Created)
}
Err(e) => Err(e),
}
}
fn create_secret(
cfg: &AwsConfig,
creds: &AwsCredentials,
name: &str,
value: &str,
) -> Result<(), AwsError> {
const TARGET: &str = "secretsmanager.CreateSecret";
let agent = http_agent();
let body = serde_json::json!({
"Name": name,
"SecretString": value,
})
.to_string();
let sig = sign(
&cfg.region,
endpoint_host(&cfg.endpoint),
TARGET,
&body,
&creds.access_key_id,
&creds.secret_access_key,
creds.session_token.as_deref(),
);
let body_clone = body.clone();
let endpoint = cfg.endpoint.clone();
let sig_date = sig.x_amz_date.clone();
let sig_auth = sig.authorization.clone();
let sig_tok = sig.x_amz_security_token.clone();
call_with_retry(|| {
let mut req = agent
.post(&endpoint)
.set("Content-Type", "application/x-amz-json-1.1")
.set("X-Amz-Target", TARGET)
.set("X-Amz-Date", &sig_date)
.set("Authorization", &sig_auth);
if let Some(ref tok) = sig_tok {
req = req.set("X-Amz-Security-Token", tok);
}
req.send_string(&body_clone)
})
.map_err(|e| map_ureq_error(e, Some(name)))?;
Ok(())
}
fn put_secret_value(
cfg: &AwsConfig,
creds: &AwsCredentials,
name: &str,
value: &str,
) -> Result<(), AwsError> {
const TARGET: &str = "secretsmanager.PutSecretValue";
let agent = http_agent();
let body = serde_json::json!({
"SecretId": name,
"SecretString": value,
})
.to_string();
let sig = sign(
&cfg.region,
endpoint_host(&cfg.endpoint),
TARGET,
&body,
&creds.access_key_id,
&creds.secret_access_key,
creds.session_token.as_deref(),
);
let body_clone = body.clone();
let endpoint = cfg.endpoint.clone();
let sig_date = sig.x_amz_date.clone();
let sig_auth = sig.authorization.clone();
let sig_tok = sig.x_amz_security_token.clone();
call_with_retry(|| {
let mut req = agent
.post(&endpoint)
.set("Content-Type", "application/x-amz-json-1.1")
.set("X-Amz-Target", TARGET)
.set("X-Amz-Date", &sig_date)
.set("Authorization", &sig_auth);
if let Some(ref tok) = sig_tok {
req = req.set("X-Amz-Security-Token", tok);
}
req.send_string(&body_clone)
})
.map_err(|e| map_ureq_error(e, Some(name)))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_creds() -> AwsCredentials {
AwsCredentials {
access_key_id: "AKID-TEST".into(),
secret_access_key: "secret-test".into(),
session_token: None,
}
}
fn cfg(url: &str) -> AwsConfig {
AwsConfig::with_endpoint("us-east-1", url)
}
fn list_response(names: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = names
.iter()
.map(|n| {
format!(r#"{{"Name":"{n}","ARN":"arn:aws:secretsmanager:us-east-1:123:{n}"}}"#)
})
.collect();
match next_token {
Some(tok) => format!(
r#"{{"SecretList":[{}],"NextToken":"{tok}"}}"#,
items.join(",")
),
None => format!(r#"{{"SecretList":[{}]}}"#, items.join(",")),
}
}
fn secret_response(value: &str) -> String {
format!(r#"{{"Name":"test","SecretString":"{value}","ARN":"arn:..."}}"#)
}
#[test]
fn normalize_name_hyphens() {
assert_eq!(normalize_name("my-secret"), "MY_SECRET");
}
#[test]
fn normalize_name_slashes() {
assert_eq!(normalize_name("myapp/db-password"), "MYAPP_DB_PASSWORD");
}
#[test]
fn normalize_name_mixed() {
assert_eq!(normalize_name("prod/my-app/api-key"), "PROD_MY_APP_API_KEY");
}
#[test]
fn pull_secrets_empty_vault() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"SecretList":[]}"#)
.create();
let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
assert!(result.is_empty());
}
#[test]
fn pull_secrets_fetches_and_normalises_key() {
let mut server = mockito::Server::new();
let _list = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["my-db-password"], None))
.create();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(secret_response("s3cr3t"))
.create();
let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].0, "MY_DB_PASSWORD");
assert_eq!(secrets[0].1, "s3cr3t");
}
#[test]
fn pull_secrets_pagination() {
let mut server = mockito::Server::new();
let _page1 = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["secret-a"], Some("page2-token")))
.expect(1)
.create();
let _page2 = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["secret-b"], None))
.expect(1)
.create();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(secret_response("val"))
.expect(2)
.create();
let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
assert_eq!(secrets.len(), 2);
let keys: Vec<&str> = secrets.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"SECRET_A"));
assert!(keys.contains(&"SECRET_B"));
}
#[test]
fn pull_secrets_resource_not_found_returns_not_found_error() {
let mut server = mockito::Server::new();
let _list = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["ghost"], None))
.create();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(400)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"__type":"ResourceNotFoundException","Message":"Secrets Manager can't find the specified secret."}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(
matches!(err, AwsError::NotFound(_)),
"expected NotFound, got {err:?}"
);
}
#[test]
fn pull_secrets_401_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.with_status(403)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"__type":"AccessDeniedException","Message":"Access denied"}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(
matches!(err, AwsError::Http { status: 403, .. }),
"expected Http 403, got {err:?}"
);
}
#[test]
fn pull_secrets_503_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.with_status(503)
.with_body("Service Unavailable")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(matches!(err, AwsError::Http { status: 503, .. }));
}
#[test]
fn pull_secrets_malformed_list_response_returns_transport_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body("not valid json {{{{")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(matches!(err, AwsError::Transport(_)));
}
#[test]
fn pull_secrets_missing_secret_list_returns_transport_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Unexpected":[]}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(
matches!(err, AwsError::Transport(_)),
"expected Transport for malformed Secrets Manager schema, got {err:?}"
);
}
#[test]
fn pull_secrets_malformed_get_response_returns_transport_error() {
let mut server = mockito::Server::new();
let _list = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["my-secret"], None))
.create();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body("not json")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(matches!(err, AwsError::Transport(_)));
}
#[test]
fn pull_secrets_429_exhausts_retries_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.with_status(429)
.with_header("Retry-After", "0")
.with_body("Too Many Requests")
.expect(MAX_RETRIES as usize + 1)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(matches!(err, AwsError::Http { status: 429, .. }));
}
#[test]
fn creds_refresh_failure_before_fetch_phase_propagates_error() {
use std::sync::atomic::{AtomicUsize, Ordering};
let call_count = AtomicUsize::new(0);
let mut server = mockito::Server::new();
let _list = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["my-secret"], None))
.create();
let err = pull_secrets(
&cfg(&server.url()),
&|| {
let n = call_count.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(test_creds())
} else {
Err(AwsError::Auth("token refresh failed".into()))
}
},
None,
)
.unwrap_err();
assert!(
matches!(err, AwsError::Auth(_)),
"expected Auth error on creds refresh, got {err:?}"
);
}
#[test]
fn creds_failure_on_first_list_call_propagates_error() {
let server = mockito::Server::new();
let err = pull_secrets(
&cfg(&server.url()),
&|| Err(AwsError::Auth("no credentials".into())),
None,
)
.unwrap_err();
assert!(matches!(err, AwsError::Auth(_)));
}
#[test]
fn x_amz_target_header_sent_for_list() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"SecretList":[]}"#)
.create();
let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
assert!(result.is_empty());
}
#[test]
fn get_secret_no_secret_string_returns_not_found() {
let mut server = mockito::Server::new();
let _list = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.ListSecrets")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(list_response(&["binary-secret"], None))
.create();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Name":"binary-secret","SecretBinary":"base64data=="}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
assert!(
matches!(err, AwsError::NotFound(_)),
"binary secrets without SecretString should return NotFound"
);
}
#[test]
fn push_secret_creates_when_not_found() {
let mut server = mockito::Server::new();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(400)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"__type":"ResourceNotFoundException","Message":"not found"}"#)
.create();
let _create = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.CreateSecret")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"ARN":"arn:aws:secretsmanager:us-east-1:123:secret:my-secret","Name":"my-secret"}"#)
.create();
let outcome = push_secret(
&cfg(&server.url()),
&|| Ok(test_creds()),
"my-secret",
"val",
)
.unwrap();
assert_eq!(outcome, PushOutcome::Created);
}
#[test]
fn push_secret_updates_when_value_differs() {
let mut server = mockito::Server::new();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Name":"my-secret","SecretString":"old-value"}"#)
.create();
let _put = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.PutSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Name":"my-secret","VersionId":"v2"}"#)
.create();
let outcome = push_secret(
&cfg(&server.url()),
&|| Ok(test_creds()),
"my-secret",
"new-value",
)
.unwrap();
assert_eq!(outcome, PushOutcome::Updated);
}
#[test]
fn push_secret_unchanged_when_value_identical() {
let mut server = mockito::Server::new();
let _get = server
.mock("POST", "/")
.match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
.with_status(200)
.with_header("Content-Type", "application/x-amz-json-1.1")
.with_body(r#"{"Name":"my-secret","SecretString":"same-value"}"#)
.create();
let no_write = server
.mock("POST", "/")
.match_header(
"X-Amz-Target",
mockito::Matcher::Regex("(CreateSecret|PutSecretValue)".to_string()),
)
.expect(0)
.create();
let outcome = push_secret(
&cfg(&server.url()),
&|| Ok(test_creds()),
"my-secret",
"same-value",
)
.unwrap();
assert_eq!(outcome, PushOutcome::Unchanged);
no_write.assert();
}
#[test]
fn push_secret_auth_error_propagates() {
let server = mockito::Server::new();
let err = push_secret(
&cfg(&server.url()),
&|| Err(AwsError::Auth("no creds".into())),
"my-secret",
"val",
)
.unwrap_err();
assert!(matches!(err, AwsError::Auth(_)));
}
}