1#![allow(clippy::result_large_err)]
4
5use super::config::{AwsConfig, AwsCredentials};
6use super::error::AwsError;
7use super::sigv4::sign;
8
9const MAX_RETRIES: u32 = 3;
10const DEFAULT_RETRY_SECS: u64 = 2;
11
12fn http_agent() -> ureq::Agent {
13 ureq::AgentBuilder::new()
14 .timeout_connect(std::time::Duration::from_secs(10))
15 .timeout(std::time::Duration::from_secs(30))
16 .build()
17}
18
19fn call_with_retry(
28 make_request: impl Fn() -> Result<ureq::Response, ureq::Error>,
29) -> Result<ureq::Response, ureq::Error> {
30 for attempt in 0..=MAX_RETRIES {
31 match make_request() {
32 Ok(resp) => return Ok(resp),
33 Err(ureq::Error::Status(429, resp)) if attempt < MAX_RETRIES => {
34 let retry_after = resp
35 .header("Retry-After")
36 .and_then(|v| v.parse::<u64>().ok())
37 .unwrap_or(DEFAULT_RETRY_SECS * 2u64.pow(attempt));
38 let wait = std::cmp::min(retry_after, 30); std::thread::sleep(std::time::Duration::from_secs(wait));
40 }
41 Err(e) => return Err(e),
42 }
43 }
44 let body = format!(
45 "secrets manager retry loop exhausted after {} attempts",
46 MAX_RETRIES + 1
47 );
48 let resp = ureq::Response::new(504, "Retries Exhausted", &body)
49 .expect("static 504 response must build");
50 Err(ureq::Error::Status(504, resp))
51}
52
53fn map_ureq_error(e: ureq::Error, secret_name: Option<&str>) -> AwsError {
54 match e {
55 ureq::Error::Status(400, resp) => {
56 let body = resp.into_string().unwrap_or_default();
57 if body.contains("ResourceNotFoundException") {
60 AwsError::NotFound(secret_name.unwrap_or("").to_string())
61 } else {
62 AwsError::Http {
63 status: 400,
64 message: body,
65 }
66 }
67 }
68 ureq::Error::Status(s, resp) => AwsError::Http {
69 status: s,
70 message: resp
71 .into_string()
72 .unwrap_or_else(|_| "<unreadable response>".into()),
73 },
74 other => AwsError::Transport(other.to_string()),
75 }
76}
77
78pub fn normalize_name(name: &str) -> String {
85 name.replace(['/', '-'], "_").to_uppercase()
86}
87
88pub fn pull_secrets(
96 cfg: &AwsConfig,
97 get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
98 prefix: Option<&str>,
99) -> Result<Vec<(String, String)>, AwsError> {
100 let names = list_secret_names(cfg, get_creds, prefix)?;
101 let creds = get_creds()?;
103 let mut secrets = Vec::new();
104
105 for name in &names {
106 let value = get_secret_value(cfg, &creds, name)?;
107 let key = normalize_name(name);
108 secrets.push((key, value));
109 }
110
111 Ok(secrets)
112}
113
114fn list_secret_names(
117 cfg: &AwsConfig,
118 get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
119 prefix: Option<&str>,
120) -> Result<Vec<String>, AwsError> {
121 const TARGET: &str = "secretsmanager.ListSecrets";
122 let agent = http_agent();
123 let mut names = Vec::new();
124 let mut next_token: Option<String> = None;
125
126 loop {
127 let creds = get_creds()?;
128
129 let body = match (&next_token, prefix) {
131 (Some(tok), Some(p)) => serde_json::json!({
132 "MaxResults": 100,
133 "Filters": [{"Key": "name", "Values": [p]}],
134 "NextToken": tok,
135 }),
136 (Some(tok), None) => serde_json::json!({
137 "MaxResults": 100,
138 "NextToken": tok,
139 }),
140 (None, Some(p)) => serde_json::json!({
141 "MaxResults": 100,
142 "Filters": [{"Key": "name", "Values": [p]}],
143 }),
144 (None, None) => serde_json::json!({ "MaxResults": 100 }),
145 };
146 let body_str = body.to_string();
147
148 let sig = sign(
149 &cfg.region,
150 TARGET,
151 &body_str,
152 &creds.access_key_id,
153 &creds.secret_access_key,
154 creds.session_token.as_deref(),
155 );
156
157 let body_clone = body_str.clone();
158 let endpoint = cfg.endpoint.clone();
159 let resp: serde_json::Value = call_with_retry(|| {
160 let mut req = agent
161 .post(&endpoint)
162 .set("Content-Type", "application/x-amz-json-1.1")
163 .set("X-Amz-Target", TARGET)
164 .set("X-Amz-Date", &sig.x_amz_date)
165 .set("Authorization", &sig.authorization);
166 if let Some(ref tok) = sig.x_amz_security_token {
167 req = req.set("X-Amz-Security-Token", tok);
168 }
169 req.send_string(&body_clone)
170 })
171 .map_err(|e| map_ureq_error(e, None))?
172 .into_json()
173 .map_err(|e| AwsError::Transport(e.to_string()))?;
174
175 let list = resp["SecretList"].as_array().ok_or_else(|| {
176 AwsError::Transport("Secrets Manager response missing 'SecretList' array".into())
177 })?;
178 for item in list {
179 if let Some(name) = item["Name"].as_str() {
180 if !name.is_empty() {
181 names.push(name.to_string());
182 }
183 }
184 }
185
186 next_token = resp["NextToken"].as_str().map(|s| s.to_string());
188 if next_token.is_none() {
189 break;
190 }
191 }
192
193 Ok(names)
194}
195
196fn get_secret_value(
199 cfg: &AwsConfig,
200 creds: &AwsCredentials,
201 name: &str,
202) -> Result<String, AwsError> {
203 const TARGET: &str = "secretsmanager.GetSecretValue";
204 let agent = http_agent();
205 let body = serde_json::json!({ "SecretId": name }).to_string();
206
207 let sig = sign(
208 &cfg.region,
209 TARGET,
210 &body,
211 &creds.access_key_id,
212 &creds.secret_access_key,
213 creds.session_token.as_deref(),
214 );
215
216 let body_clone = body.clone();
217 let endpoint = cfg.endpoint.clone();
218 let sig_date = sig.x_amz_date.clone();
219 let sig_auth = sig.authorization.clone();
220 let sig_tok = sig.x_amz_security_token.clone();
221
222 let resp: serde_json::Value = call_with_retry(|| {
223 let mut req = agent
224 .post(&endpoint)
225 .set("Content-Type", "application/x-amz-json-1.1")
226 .set("X-Amz-Target", TARGET)
227 .set("X-Amz-Date", &sig_date)
228 .set("Authorization", &sig_auth);
229 if let Some(ref tok) = sig_tok {
230 req = req.set("X-Amz-Security-Token", tok);
231 }
232 req.send_string(&body_clone)
233 })
234 .map_err(|e| map_ureq_error(e, Some(name)))?
235 .into_json()
236 .map_err(|e| AwsError::Transport(e.to_string()))?;
237
238 resp["SecretString"]
240 .as_str()
241 .map(|s| s.to_string())
242 .ok_or_else(|| AwsError::NotFound(name.to_string()))
243}
244
245#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn test_creds() -> AwsCredentials {
252 AwsCredentials {
253 access_key_id: "AKID-TEST".into(),
254 secret_access_key: "secret-test".into(),
255 session_token: None,
256 }
257 }
258
259 fn cfg(url: &str) -> AwsConfig {
260 AwsConfig::with_endpoint("us-east-1", url)
261 }
262
263 fn list_response(names: &[&str], next_token: Option<&str>) -> String {
264 let items: Vec<String> = names
265 .iter()
266 .map(|n| {
267 format!(r#"{{"Name":"{n}","ARN":"arn:aws:secretsmanager:us-east-1:123:{n}"}}"#)
268 })
269 .collect();
270 match next_token {
271 Some(tok) => format!(
272 r#"{{"SecretList":[{}],"NextToken":"{tok}"}}"#,
273 items.join(",")
274 ),
275 None => format!(r#"{{"SecretList":[{}]}}"#, items.join(",")),
276 }
277 }
278
279 fn secret_response(value: &str) -> String {
280 format!(r#"{{"Name":"test","SecretString":"{value}","ARN":"arn:..."}}"#)
281 }
282
283 #[test]
286 fn normalize_name_hyphens() {
287 assert_eq!(normalize_name("my-secret"), "MY_SECRET");
288 }
289
290 #[test]
291 fn normalize_name_slashes() {
292 assert_eq!(normalize_name("myapp/db-password"), "MYAPP_DB_PASSWORD");
293 }
294
295 #[test]
296 fn normalize_name_mixed() {
297 assert_eq!(normalize_name("prod/my-app/api-key"), "PROD_MY_APP_API_KEY");
298 }
299
300 #[test]
303 fn pull_secrets_empty_vault() {
304 let mut server = mockito::Server::new();
305 let _m = server
306 .mock("POST", "/")
307 .with_status(200)
308 .with_header("Content-Type", "application/x-amz-json-1.1")
309 .with_body(r#"{"SecretList":[]}"#)
310 .create();
311
312 let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
313 assert!(result.is_empty());
314 }
315
316 #[test]
317 fn pull_secrets_fetches_and_normalises_key() {
318 let mut server = mockito::Server::new();
319 let _list = server
320 .mock("POST", "/")
321 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
322 .with_status(200)
323 .with_header("Content-Type", "application/x-amz-json-1.1")
324 .with_body(list_response(&["my-db-password"], None))
325 .create();
326 let _get = server
327 .mock("POST", "/")
328 .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
329 .with_status(200)
330 .with_header("Content-Type", "application/x-amz-json-1.1")
331 .with_body(secret_response("s3cr3t"))
332 .create();
333
334 let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
335 assert_eq!(secrets.len(), 1);
336 assert_eq!(secrets[0].0, "MY_DB_PASSWORD");
337 assert_eq!(secrets[0].1, "s3cr3t");
338 }
339
340 #[test]
341 fn pull_secrets_pagination() {
342 let mut server = mockito::Server::new();
343 let _page1 = server
344 .mock("POST", "/")
345 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
346 .with_status(200)
347 .with_header("Content-Type", "application/x-amz-json-1.1")
348 .with_body(list_response(&["secret-a"], Some("page2-token")))
349 .expect(1)
350 .create();
351 let _page2 = server
352 .mock("POST", "/")
353 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
354 .with_status(200)
355 .with_header("Content-Type", "application/x-amz-json-1.1")
356 .with_body(list_response(&["secret-b"], None))
357 .expect(1)
358 .create();
359 let _get = server
361 .mock("POST", "/")
362 .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
363 .with_status(200)
364 .with_header("Content-Type", "application/x-amz-json-1.1")
365 .with_body(secret_response("val"))
366 .expect(2)
367 .create();
368
369 let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
370 assert_eq!(secrets.len(), 2);
371 let keys: Vec<&str> = secrets.iter().map(|(k, _)| k.as_str()).collect();
372 assert!(keys.contains(&"SECRET_A"));
373 assert!(keys.contains(&"SECRET_B"));
374 }
375
376 #[test]
377 fn pull_secrets_resource_not_found_returns_not_found_error() {
378 let mut server = mockito::Server::new();
379 let _list = server
380 .mock("POST", "/")
381 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
382 .with_status(200)
383 .with_header("Content-Type", "application/x-amz-json-1.1")
384 .with_body(list_response(&["ghost"], None))
385 .create();
386 let _get = server
387 .mock("POST", "/")
388 .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
389 .with_status(400)
390 .with_header("Content-Type", "application/x-amz-json-1.1")
391 .with_body(r#"{"__type":"ResourceNotFoundException","Message":"Secrets Manager can't find the specified secret."}"#)
392 .create();
393
394 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
395 assert!(
396 matches!(err, AwsError::NotFound(_)),
397 "expected NotFound, got {err:?}"
398 );
399 }
400
401 #[test]
402 fn pull_secrets_401_returns_http_error() {
403 let mut server = mockito::Server::new();
404 let _m = server
405 .mock("POST", "/")
406 .with_status(403)
407 .with_header("Content-Type", "application/x-amz-json-1.1")
408 .with_body(r#"{"__type":"AccessDeniedException","Message":"Access denied"}"#)
409 .create();
410
411 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
412 assert!(
413 matches!(err, AwsError::Http { status: 403, .. }),
414 "expected Http 403, got {err:?}"
415 );
416 }
417
418 #[test]
419 fn pull_secrets_503_returns_http_error() {
420 let mut server = mockito::Server::new();
421 let _m = server
422 .mock("POST", "/")
423 .with_status(503)
424 .with_body("Service Unavailable")
425 .create();
426
427 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
428 assert!(matches!(err, AwsError::Http { status: 503, .. }));
429 }
430
431 #[test]
432 fn pull_secrets_malformed_list_response_returns_transport_error() {
433 let mut server = mockito::Server::new();
434 let _m = server
435 .mock("POST", "/")
436 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
437 .with_status(200)
438 .with_header("Content-Type", "application/x-amz-json-1.1")
439 .with_body("not valid json {{{{")
440 .create();
441
442 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
443 assert!(matches!(err, AwsError::Transport(_)));
444 }
445
446 #[test]
447 fn pull_secrets_missing_secret_list_returns_transport_error() {
448 let mut server = mockito::Server::new();
449 let _m = server
450 .mock("POST", "/")
451 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
452 .with_status(200)
453 .with_header("Content-Type", "application/x-amz-json-1.1")
454 .with_body(r#"{"Unexpected":[]}"#)
455 .create();
456
457 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
458 assert!(
459 matches!(err, AwsError::Transport(_)),
460 "expected Transport for malformed Secrets Manager schema, got {err:?}"
461 );
462 }
463
464 #[test]
465 fn pull_secrets_malformed_get_response_returns_transport_error() {
466 let mut server = mockito::Server::new();
467 let _list = server
468 .mock("POST", "/")
469 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
470 .with_status(200)
471 .with_header("Content-Type", "application/x-amz-json-1.1")
472 .with_body(list_response(&["my-secret"], None))
473 .create();
474 let _get = server
475 .mock("POST", "/")
476 .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
477 .with_status(200)
478 .with_header("Content-Type", "application/x-amz-json-1.1")
479 .with_body("not json")
480 .create();
481
482 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
483 assert!(matches!(err, AwsError::Transport(_)));
484 }
485
486 #[test]
487 fn pull_secrets_429_exhausts_retries_returns_http_error() {
488 let mut server = mockito::Server::new();
489 let _m = server
490 .mock("POST", "/")
491 .with_status(429)
492 .with_header("Retry-After", "0")
493 .with_body("Too Many Requests")
494 .expect(MAX_RETRIES as usize + 1)
495 .create();
496
497 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
498 assert!(matches!(err, AwsError::Http { status: 429, .. }));
499 }
500
501 #[test]
502 fn creds_refresh_failure_before_fetch_phase_propagates_error() {
503 use std::sync::atomic::{AtomicUsize, Ordering};
504 let call_count = AtomicUsize::new(0);
505
506 let mut server = mockito::Server::new();
507 let _list = server
508 .mock("POST", "/")
509 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
510 .with_status(200)
511 .with_header("Content-Type", "application/x-amz-json-1.1")
512 .with_body(list_response(&["my-secret"], None))
513 .create();
514
515 let err = pull_secrets(
516 &cfg(&server.url()),
517 &|| {
518 let n = call_count.fetch_add(1, Ordering::SeqCst);
519 if n == 0 {
520 Ok(test_creds())
521 } else {
522 Err(AwsError::Auth("token refresh failed".into()))
523 }
524 },
525 None,
526 )
527 .unwrap_err();
528
529 assert!(
530 matches!(err, AwsError::Auth(_)),
531 "expected Auth error on creds refresh, got {err:?}"
532 );
533 }
534
535 #[test]
536 fn creds_failure_on_first_list_call_propagates_error() {
537 let server = mockito::Server::new();
538 let err = pull_secrets(
540 &cfg(&server.url()),
541 &|| Err(AwsError::Auth("no credentials".into())),
542 None,
543 )
544 .unwrap_err();
545 assert!(matches!(err, AwsError::Auth(_)));
546 }
547
548 #[test]
549 fn x_amz_target_header_sent_for_list() {
550 let mut server = mockito::Server::new();
551 let _m = server
552 .mock("POST", "/")
553 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
554 .with_status(200)
555 .with_header("Content-Type", "application/x-amz-json-1.1")
556 .with_body(r#"{"SecretList":[]}"#)
557 .create();
558
559 let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
560 assert!(result.is_empty());
561 }
563
564 #[test]
565 fn get_secret_no_secret_string_returns_not_found() {
566 let mut server = mockito::Server::new();
567 let _list = server
568 .mock("POST", "/")
569 .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
570 .with_status(200)
571 .with_header("Content-Type", "application/x-amz-json-1.1")
572 .with_body(list_response(&["binary-secret"], None))
573 .create();
574 let _get = server
576 .mock("POST", "/")
577 .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
578 .with_status(200)
579 .with_header("Content-Type", "application/x-amz-json-1.1")
580 .with_body(r#"{"Name":"binary-secret","SecretBinary":"base64data=="}"#)
581 .create();
582
583 let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
584 assert!(
585 matches!(err, AwsError::NotFound(_)),
586 "binary secrets without SecretString should return NotFound"
587 );
588 }
589}