1use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
22use url::Url;
23
24#[derive(Debug)]
29pub struct AwsSsmUrl {
30 pub region: String,
31 pub parameter_name: String,
32 pub with_decryption: bool,
33}
34
35impl TryFrom<&Url> for AwsSsmUrl {
36 type Error = Error;
37
38 fn try_from(url: &Url) -> Result<Self, Self::Error> {
39 if url.scheme() != "aws-ssm" {
40 return Err(Error::InvalidUrl("expected aws-ssm:// scheme".into()));
41 }
42
43 let region = url
44 .host_str()
45 .ok_or_else(|| Error::InvalidUrl("aws-ssm:// requires a region (host)".into()))?
46 .to_owned();
47 if region.is_empty() {
48 return Err(Error::InvalidUrl(
49 "aws-ssm:// region must not be empty".into(),
50 ));
51 }
52
53 let parameter_name = url.path().trim_start_matches('/').to_owned();
54 if parameter_name.is_empty() {
55 return Err(Error::InvalidUrl(
56 "aws-ssm:// parameter name must not be empty".into(),
57 ));
58 }
59
60 let mut with_decryption = true;
61
62 for (k, v) in url.query_pairs() {
63 match k.as_ref() {
64 "with-decryption" => {
65 with_decryption = match v.as_ref() {
66 "true" => true,
67 "false" => false,
68 _ => {
69 return Err(Error::InvalidUrl(format!(
70 "aws-ssm:// with-decryption must be true or false, got {v}"
71 )));
72 }
73 };
74 }
75 _ => {
76 return Err(Error::InvalidUrl(format!(
77 "aws-ssm:// unknown query parameter: {k}"
78 )));
79 }
80 }
81 }
82
83 Ok(AwsSsmUrl {
84 region,
85 parameter_name,
86 with_decryption,
87 })
88 }
89}
90
91#[derive(Debug)]
102pub struct AwsSsmBackend {
103 init: Result<tokio::runtime::Runtime, Error>,
104}
105
106impl AwsSsmBackend {
107 pub fn new() -> Self {
112 Self {
113 init: tokio::runtime::Builder::new_current_thread()
114 .enable_io()
115 .enable_time()
116 .build()
117 .map_err(|e| Error::Backend {
118 scheme: "aws-ssm",
119 kind: BackendFailureKind::Permanent,
120 message: format!("failed to create tokio runtime: {e}"),
121 }),
122 }
123 }
124
125 pub fn with_proxy(_proxy: Option<hasp_core::ProxyConfig>) -> Self {
131 Self::new()
132 }
133
134 fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
135 self.init.as_ref().map_err(|e| e.clone())
136 }
137
138 fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
139 where
140 F: std::future::Future,
141 {
142 let rt = self.runtime()?;
143 Ok(rt.block_on(future))
144 }
145}
146
147impl Default for AwsSsmBackend {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl Backend for AwsSsmBackend {
154 fn scheme(&self) -> &'static str {
155 "aws-ssm"
156 }
157
158 fn validate(&self, url: &Url) -> Result<(), Error> {
159 AwsSsmUrl::try_from(url).map(|_| ())
160 }
161
162 fn get(&self, url: &Url) -> Result<SecretString, Error> {
163 let aws_url = AwsSsmUrl::try_from(url)?;
164 self.block_on(get_parameter(&aws_url, aws_url.with_decryption))?
165 }
166
167 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
168 let aws_url = AwsSsmUrl::try_from(url)?;
169 self.block_on(put_parameter(&aws_url, value.expose_secret()))?
170 }
171
172 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
173 let aws_url = AwsSsmUrl::try_from(url)?;
174 self.block_on(list_parameters(&aws_url))?
175 }
176
177 fn delete(&self, url: &Url) -> Result<(), Error> {
178 let aws_url = AwsSsmUrl::try_from(url)?;
179 self.block_on(delete_parameter(&aws_url))?
180 }
181
182 fn exists(&self, url: &Url) -> Result<bool, Error> {
183 let aws_url = AwsSsmUrl::try_from(url)?;
184 match self.block_on(get_parameter(&aws_url, false)) {
185 Ok(_) => Ok(true),
186 Err(Error::NotFound(_)) => Ok(false),
187 Err(e) => Err(e),
188 }
189 }
190}
191
192async fn aws_config_for_region(region: &str) -> aws_config::SdkConfig {
194 aws_config::defaults(aws_config::BehaviorVersion::latest())
195 .region(aws_config::Region::new(region.to_string()))
196 .load()
197 .await
198}
199
200async fn get_parameter(aws_url: &AwsSsmUrl, with_decryption: bool) -> Result<SecretString, Error> {
206 let config = aws_config_for_region(&aws_url.region).await;
207 let client = aws_sdk_ssm::Client::new(&config);
208
209 let output = client
210 .get_parameter()
211 .name(&aws_url.parameter_name)
212 .with_decryption(with_decryption)
213 .send()
214 .await
215 .map_err(map_get_error)?;
216
217 let parameter = output.parameter.ok_or_else(|| Error::Backend {
218 scheme: "aws-ssm",
219 kind: BackendFailureKind::Permanent,
220 message: "AWS returned an empty parameter object".into(),
221 })?;
222
223 let value = parameter.value.ok_or_else(|| Error::Backend {
224 scheme: "aws-ssm",
225 kind: BackendFailureKind::Permanent,
226 message: "AWS returned a parameter with no value".into(),
227 })?;
228
229 Ok(SecretString::new(value.into()))
230}
231
232async fn put_parameter(aws_url: &AwsSsmUrl, value: &str) -> Result<(), Error> {
238 let config = aws_config_for_region(&aws_url.region).await;
239 let client = aws_sdk_ssm::Client::new(&config);
240
241 client
242 .put_parameter()
243 .name(&aws_url.parameter_name)
244 .value(value)
245 .r#type(aws_sdk_ssm::types::ParameterType::SecureString)
246 .overwrite(true)
247 .send()
248 .await
249 .map_err(map_put_error)?;
250
251 Ok(())
252}
253
254async fn list_parameters(aws_url: &AwsSsmUrl) -> Result<Vec<Entry>, Error> {
260 let config = aws_config_for_region(&aws_url.region).await;
261 let client = aws_sdk_ssm::Client::new(&config);
262
263 let mut entries = Vec::new();
264 let mut next_token: Option<String> = None;
265 const MAX_PAGES: usize = 500;
266
267 for _ in 0..MAX_PAGES {
268 let mut builder = client
269 .get_parameters_by_path()
270 .path(&aws_url.parameter_name)
271 .recursive(true);
272
273 if let Some(ref token) = next_token {
274 builder = builder.next_token(token);
275 }
276
277 let output = builder.send().await.map_err(map_list_error)?;
278 next_token = output.next_token.clone();
279
280 for param in output.parameters.into_iter().flatten() {
281 let name = param.name.unwrap_or_default();
282 if name.is_empty() {
283 continue;
284 }
285 let entry_url = Url::parse(&format!(
286 "aws-ssm://{}/{}?with-decryption={}",
287 aws_url.region, name, aws_url.with_decryption,
288 ))
289 .map_err(|e| Error::Backend {
290 scheme: "aws-ssm",
291 kind: BackendFailureKind::Permanent,
292 message: format!("failed to build list entry URL: {e}"),
293 })?;
294 entries.push(Entry {
295 name,
296 url: entry_url,
297 });
298 }
299
300 if next_token.is_none() {
301 break;
302 }
303 }
304
305 Ok(entries)
306}
307
308async fn delete_parameter(aws_url: &AwsSsmUrl) -> Result<(), Error> {
312 let config = aws_config_for_region(&aws_url.region).await;
313 let client = aws_sdk_ssm::Client::new(&config);
314
315 client
316 .delete_parameter()
317 .name(&aws_url.parameter_name)
318 .send()
319 .await
320 .map_err(map_delete_error)?;
321
322 Ok(())
323}
324
325fn map_get_error(
328 err: aws_sdk_ssm::error::SdkError<aws_sdk_ssm::operation::get_parameter::GetParameterError>,
329) -> Error {
330 if let Some(service_err) = err.as_service_error() {
331 let code = service_err.meta().code().unwrap_or("Unknown");
332 let message = service_err.meta().message().unwrap_or("no message");
333 return from_service_error(code, message);
334 }
335 map_generic_error(err)
336}
337
338fn map_put_error(
340 err: aws_sdk_ssm::error::SdkError<aws_sdk_ssm::operation::put_parameter::PutParameterError>,
341) -> Error {
342 if let Some(service_err) = err.as_service_error() {
343 let code = service_err.meta().code().unwrap_or("Unknown");
344 let message = service_err.meta().message().unwrap_or("no message");
345 return from_service_error(code, message);
346 }
347 map_generic_error(err)
348}
349
350fn map_list_error(
352 err: aws_sdk_ssm::error::SdkError<
353 aws_sdk_ssm::operation::get_parameters_by_path::GetParametersByPathError,
354 >,
355) -> Error {
356 if let Some(service_err) = err.as_service_error() {
357 let code = service_err.meta().code().unwrap_or("Unknown");
358 let message = service_err.meta().message().unwrap_or("no message");
359 return from_service_error(code, message);
360 }
361 map_generic_error(err)
362}
363
364fn map_delete_error(
366 err: aws_sdk_ssm::error::SdkError<
367 aws_sdk_ssm::operation::delete_parameter::DeleteParameterError,
368 >,
369) -> Error {
370 if let Some(service_err) = err.as_service_error() {
371 let code = service_err.meta().code().unwrap_or("Unknown");
372 let message = service_err.meta().message().unwrap_or("no message");
373 return from_service_error(code, message);
374 }
375 map_generic_error(err)
376}
377
378fn from_service_error(code: &str, message: &str) -> Error {
381 match code {
382 "ParameterNotFound" | "ParameterVersionNotFound" => {
383 Error::NotFound(format!("aws-ssm:// parameter not found: {message}"))
384 }
385 "InvalidParameterException" | "InvalidParameterValue" | "ParameterPatternMismatch" => {
386 Error::InvalidUrl(format!("aws-ssm:// invalid parameter: {message}"))
387 }
388 "InvalidRequestException" => {
389 Error::PreconditionFailed(format!("aws-ssm:// request precondition failed: {message}"))
390 }
391 "AccessDeniedException" => {
392 Error::PermissionDenied(format!("aws-ssm:// permission denied: {message}"))
393 }
394 "UnauthorizedException" => {
395 Error::AuthenticationFailed(format!("aws-ssm:// authentication failed: {message}"))
396 }
397 "ThrottlingException" | "TooManyUpdates" => Error::Backend {
398 scheme: "aws-ssm",
399 kind: BackendFailureKind::Throttled,
400 message: format!("AWS throttled the request: {message}"),
401 },
402 "InternalServerError" => Error::Backend {
403 scheme: "aws-ssm",
404 kind: BackendFailureKind::Transient,
405 message: format!("AWS service error ({code}): {message}"),
406 },
407 "HierarchyDepthLimitExceeded" | "ParameterAlreadyExists" | "ParameterLimitExceeded" => {
408 Error::PreconditionFailed(format!("aws-ssm:// precondition failed: {message}"))
409 }
410 "InvalidKeyId" | "UnsupportedParameterType" => Error::Backend {
411 scheme: "aws-ssm",
412 kind: BackendFailureKind::Permanent,
413 message: format!("AWS service error ({code}): {message}"),
414 },
415 _ => Error::Backend {
416 scheme: "aws-ssm",
417 kind: BackendFailureKind::Permanent,
418 message: format!("AWS service error ({code}): {message}"),
419 },
420 }
421}
422
423fn map_generic_error<E: std::fmt::Display>(err: aws_sdk_ssm::error::SdkError<E>) -> Error {
426 use aws_sdk_ssm::error::SdkError;
427 match err {
428 SdkError::TimeoutError(_) => Error::Backend {
429 scheme: "aws-ssm",
430 kind: BackendFailureKind::Transient,
431 message: "AWS request timed out".into(),
432 },
433 SdkError::DispatchFailure(_) => Error::Backend {
434 scheme: "aws-ssm",
435 kind: BackendFailureKind::Transient,
436 message: "AWS request dispatch failed".into(),
437 },
438 _ => Error::Backend {
439 scheme: "aws-ssm",
440 kind: BackendFailureKind::Permanent,
441 message: format!("AWS SDK error: {err}"),
442 },
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn parse_valid_url_simple() {
452 let url = Url::parse("aws-ssm://us-east-1/my-param").unwrap();
453 let aws = AwsSsmUrl::try_from(&url).unwrap();
454 assert_eq!(aws.region, "us-east-1");
455 assert_eq!(aws.parameter_name, "my-param");
456 assert!(aws.with_decryption);
457 }
458
459 #[test]
460 fn parse_valid_url_with_path() {
461 let url = Url::parse("aws-ssm://us-west-2/prod/app/db-password").unwrap();
462 let aws = AwsSsmUrl::try_from(&url).unwrap();
463 assert_eq!(aws.region, "us-west-2");
464 assert_eq!(aws.parameter_name, "prod/app/db-password");
465 }
466
467 #[test]
468 fn parse_valid_url_with_decryption_false() {
469 let url = Url::parse("aws-ssm://eu-west-1/my-param?with-decryption=false").unwrap();
470 let aws = AwsSsmUrl::try_from(&url).unwrap();
471 assert!(!aws.with_decryption);
472 }
473
474 #[test]
475 fn parse_valid_url_with_decryption_true() {
476 let url = Url::parse("aws-ssm://ap-south-1/my-param?with-decryption=true").unwrap();
477 let aws = AwsSsmUrl::try_from(&url).unwrap();
478 assert!(aws.with_decryption);
479 }
480
481 #[test]
482 fn parse_missing_host_fails() {
483 let url = Url::parse("aws-ssm:///my-param").unwrap();
484 assert!(AwsSsmUrl::try_from(&url).is_err());
485 }
486
487 #[test]
488 fn parse_empty_path_fails() {
489 let url = Url::parse("aws-ssm://us-east-1/").unwrap();
490 assert!(AwsSsmUrl::try_from(&url).is_err());
491 }
492
493 #[test]
494 fn parse_unknown_query_fails() {
495 let url = Url::parse("aws-ssm://us-east-1/my-param?version=1").unwrap();
496 assert!(AwsSsmUrl::try_from(&url).is_err());
497 }
498
499 #[test]
500 fn parse_invalid_decryption_value_fails() {
501 let url = Url::parse("aws-ssm://us-east-1/my-param?with-decryption=maybe").unwrap();
502 assert!(AwsSsmUrl::try_from(&url).is_err());
503 }
504
505 #[test]
506 fn error_map_parameter_not_found() {
507 let err = from_service_error("ParameterNotFound", "parameter not found");
508 assert!(matches!(err, Error::NotFound(ref s) if s.contains("parameter not found")));
509 }
510
511 #[test]
512 fn error_map_parameter_version_not_found() {
513 let err = from_service_error("ParameterVersionNotFound", "version gone");
514 assert!(matches!(err, Error::NotFound(ref s) if s.contains("version gone")));
515 }
516
517 #[test]
518 fn error_map_invalid_parameter() {
519 let err = from_service_error("InvalidParameterException", "bad param");
520 assert!(matches!(err, Error::InvalidUrl(ref s) if s.contains("bad param")));
521 }
522
523 #[test]
524 fn error_map_invalid_request() {
525 let err = from_service_error("InvalidRequestException", "bad request");
526 assert!(matches!(err, Error::PreconditionFailed(ref s) if s.contains("bad request")));
527 }
528
529 #[test]
530 fn error_map_access_denied() {
531 let err = from_service_error("AccessDeniedException", "denied");
532 assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("denied")));
533 }
534
535 #[test]
536 fn error_map_unauthorized() {
537 let err = from_service_error("UnauthorizedException", "who are you");
538 assert!(matches!(err, Error::AuthenticationFailed(ref s) if s.contains("who are you")));
539 }
540
541 #[test]
542 fn error_map_throttling() {
543 let err = from_service_error("ThrottlingException", "slow down");
544 assert!(matches!(
545 err,
546 Error::Backend {
547 kind: BackendFailureKind::Throttled,
548 ..
549 }
550 ));
551 }
552
553 #[test]
554 fn error_map_internal_server_error_is_transient() {
555 let err = from_service_error("InternalServerError", "oops");
556 assert!(matches!(
557 err,
558 Error::Backend {
559 kind: BackendFailureKind::Transient,
560 ..
561 }
562 ));
563 }
564
565 #[test]
566 fn error_map_unknown_code_is_permanent() {
567 let err = from_service_error("SomeWeirdException", "unknown");
568 assert!(matches!(
569 err,
570 Error::Backend {
571 kind: BackendFailureKind::Permanent,
572 ..
573 }
574 ));
575 }
576
577 #[test]
578 fn supported_operations() {
579 let _backend = AwsSsmBackend::new();
580 }
583}