diff --git a/sources/Cargo.lock b/sources/Cargo.lock index 873362640..9229237bc 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -395,12 +395,18 @@ checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" name = "apiclient" version = "0.1.0" dependencies = [ + "async-trait", + "aws-config", "aws-lc-rs", + "aws-sdk-s3", + "aws-sdk-secretsmanager", + "aws-sdk-ssm", "base64 0.22.1", "constants", "datastore", "futures", "futures-channel", + "futures-util", "generate-readme", "http 0.2.12", "httparse", @@ -419,6 +425,8 @@ dependencies = [ "signal-hook", "simplelog", "snafu", + "tempfile", + "test-case", "tokio", "tokio-tungstenite", "toml", @@ -709,6 +717,7 @@ dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", + "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -796,6 +805,86 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-s3" +version = "1.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "154488d16ab0d627d15ab2832b57e68a16684c8c902f14cb8a75ec933fc94852" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http-body 0.4.6", + "lru", + "once_cell", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde1b6dfc07710a45e8ba12c821637bf756bca9f49b3571990419fb7a72f26" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssm" +version = "1.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7147fba442841a93a9b9f8c251b1ddb3f4d6609780c60aa8d79ed7ba0c4a77" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.51.0" @@ -870,20 +959,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d3820e0c08d0737872ff3c7c1f21ebbb6693d832312d6152bf18ef50a5471c2" dependencies = [ "aws-credential-types", + "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", + "crypto-bigint 0.5.5", "form_urlencoded", "hex", "hmac", "http 0.2.12", "http 1.2.0", "once_cell", + "p256", "percent-encoding", + "ring", "sha2", + "subtle", "time", "tracing", + "zeroize", ] [[package]] @@ -897,6 +992,38 @@ dependencies = [ "tokio", ] +[[package]] +name = "aws-smithy-checksums" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1a71073fca26775c8b5189175ea8863afb1c9ea2cceb02a5de5ad9dfbaa795" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc32c", + "crc32fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef7d0a272725f87e51ba2bf89f8c21e4df61b9e49ae1ac367a6d69916ef7c90" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + [[package]] name = "aws-smithy-experimental" version = "0.1.4" @@ -927,6 +1054,7 @@ version = "0.60.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8bc3e8fdc6b8d07d976e301c02fe553f72a39b7a9fea820e023268467d7ab6" dependencies = [ + "aws-smithy-eventstream", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -1092,6 +1220,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.21.7" @@ -1114,6 +1248,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bincode" version = "1.3.3" @@ -1834,6 +1974,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_panic" version = "0.2.11" @@ -1926,6 +2072,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -1956,6 +2111,28 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -2058,6 +2235,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -2255,12 +2442,44 @@ dependencies = [ "tokio", ] +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2336,6 +2555,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "filetime" version = "0.2.25" @@ -2364,6 +2593,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2576,6 +2811,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -2662,6 +2908,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "headers" @@ -3383,6 +3634,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "lz4" version = "1.28.0" @@ -3417,6 +3677,16 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -3756,6 +4026,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -3918,6 +4199,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pluto" version = "0.1.0" @@ -4296,6 +4587,17 @@ dependencies = [ "generate-readme", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "ring" version = "0.17.13" @@ -4623,6 +4925,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5152,6 +5468,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "signpost" version = "0.1.0" @@ -5243,6 +5569,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/sources/Cargo.toml b/sources/Cargo.toml index c72f19c6b..41b86ba35 100644 --- a/sources/Cargo.toml +++ b/sources/Cargo.toml @@ -114,6 +114,9 @@ aws-lc-rs = "1" aws-sdk-cloudformation = "1" aws-sdk-ec2 = "1" aws-sdk-eks = "1" +aws-sdk-s3 = "1" +aws-sdk-secretsmanager = "1" +aws-sdk-ssm = "1" aws-smithy-runtime = "1" aws-smithy-runtime-api = "1" aws-smithy-types = "1" diff --git a/sources/api/apiclient/Cargo.toml b/sources/api/apiclient/Cargo.toml index a99df571b..457c94f1a 100644 --- a/sources/api/apiclient/Cargo.toml +++ b/sources/api/apiclient/Cargo.toml @@ -15,11 +15,17 @@ tls = ["dep:rustls", "dep:aws-lc-rs", "reqwest/rustls-tls-native-roots"] fips = ["tls", "aws-lc-rs/fips", "rustls/fips"] [dependencies] +async-trait.workspace = true +aws-config.workspace = true aws-lc-rs = { workspace = true, optional = true, features = ["bindgen"] } +aws-sdk-s3.workspace = true +aws-sdk-secretsmanager.workspace = true +aws-sdk-ssm.workspace = true base64.workspace = true constants.workspace = true datastore.workspace = true futures.workspace = true +futures-util.workspace = true futures-channel.workspace = true http.workspace = true httparse.workspace = true @@ -47,3 +53,7 @@ url.workspace = true [build-dependencies] generate-readme.workspace = true + +[dev-dependencies] +tempfile.workspace = true +test-case.workspace = true diff --git a/sources/api/apiclient/src/apply.rs b/sources/api/apiclient/src/apply.rs index d072dc49c..f0ca8fb0b 100644 --- a/sources/api/apiclient/src/apply.rs +++ b/sources/api/apiclient/src/apply.rs @@ -1,15 +1,15 @@ //! This module allows application of settings from URIs or stdin. The inputs are expected to be //! TOML settings files, in the same format as user data, or the JSON equivalent. The inputs are //! pulled and applied to the API server in a single transaction. - +use crate::apply::error::ResolverFailureSnafu; use crate::rando; -use futures::future::{join, ready, TryFutureExt}; +use futures::future::{join, ready}; use futures::stream::{self, StreamExt}; use reqwest::Url; use serde::de::{Deserialize, IntoDeserializer}; -use snafu::{futures::try_future::TryFutureExt as SnafuTryFutureExt, OptionExt, ResultExt}; +use snafu::{OptionExt, ResultExt}; +use std::convert::TryFrom; use std::path::Path; -use tokio::io::AsyncReadExt; /// Reads settings in TOML or JSON format from files at the requested URIs (or from stdin, if given /// "-"), then commits them in a single transaction and applies them to the system. @@ -66,47 +66,67 @@ where Ok(()) } +/// Holds the raw input string and the URL (if it parses). +pub struct SettingsInput { + pub input: String, + pub parsed_url: Option, +} +impl SettingsInput { + pub(crate) fn new(input: impl Into) -> Self { + let input = input.into(); + let parsed_url = match Url::parse(&input) { + Ok(url) => Some(url), + Err(err) => { + log::debug!("URL parse failed for '{}': {}", input, err); + None + } + }; + SettingsInput { input, parsed_url } + } +} + /// Retrieves the given source location and returns the result in a String. async fn get(input_source: S) -> Result where - S: Into, + S: AsRef, { - let input_source = input_source.into(); - - // Read from stdin if "-" was given. - if input_source == "-" { - let mut output = String::new(); - tokio::io::stdin() - .read_to_string(&mut output) - .context(error::StdinReadSnafu) - .await?; - return Ok(output); - } + let settings = SettingsInput::new(input_source.as_ref()); + let resolver = select_resolver(&settings)?; + resolver.resolve().await.context(ResolverFailureSnafu) +} - // Otherwise, the input should be a URI; parse it to know what kind. - // Until reqwest handles file:// URIs: https://github.com/seanmonstar/reqwest/issues/178 - let uri = Url::parse(&input_source).context(error::UriSnafu { - input_source: &input_source, - })?; - if uri.scheme() == "file" { - // Turn the URI to a file path, and return a future that reads it. - let path = uri.to_file_path().ok().context(error::FileUriSnafu { - input_source: &input_source, - })?; - tokio::fs::read_to_string(path) - .context(error::FileReadSnafu { input_source }) - .await - } else { - // Return a future that contains the text of the (non-file) URI. - reqwest::get(uri) - .and_then(|response| ready(response.error_for_status())) - .and_then(|response| response.text()) - .context(error::ReqwestSnafu { - uri: input_source, - method: "GET", - }) - .await +/// Macro to try multiple settings resolver types in sequence, returning the first one that succeeds. +macro_rules! try_resolvers { + ($input:expr, $($resolver_type:ty),+ $(,)?) => { + $( + if let Ok(r) = <$resolver_type>::try_from($input) { + log::debug!("select_resolver: picked {}", stringify!($resolver_type)); + return Ok(Box::new(r)); + } + )+ + }; +} + +/// Choose which UriResolver applies to `input` (stdin, file://, http(s)://, s3://, secretsmanager://, and ssm://). +fn select_resolver(input: &SettingsInput) -> Result> { + use crate::uri_resolver::*; + + try_resolvers!( + input, + StdinUri, + FileUri, + HttpUri, + S3Uri, + SecretsManagerArn, + SecretsManagerUri, + SsmArn, + SsmUri, + ); + + error::NoResolverSnafu { + input_source: input.input.clone(), } + .fail() } /// Takes a string of TOML or JSON settings data and reserializes @@ -155,14 +175,8 @@ mod error { source: Box, }, - #[snafu(display("Failed to read given file '{}': {}", input_source, source))] - FileRead { - input_source: String, - source: std::io::Error, - }, - - #[snafu(display("Given invalid file URI '{}'", input_source))] - FileUri { input_source: String }, + #[snafu(display("No URI resolver found for '{}'", input_source))] + NoResolver { input_source: String }, #[snafu(display( "Input '{}' is not valid TOML or JSON. (TOML error: {}) (JSON error: {})", @@ -218,9 +232,6 @@ mod error { source: reqwest::Error, }, - #[snafu(display("Failed to read standard input: {}", source))] - StdinRead { source: std::io::Error }, - #[snafu(display( "Failed to translate TOML from '{}' to JSON for API: {}", input_source, @@ -228,7 +239,8 @@ mod error { ))] TomlToJson { input_source: String, - source: toml::de::Error, + #[snafu(source(from(toml::de::Error, Box::new)))] + source: Box, }, #[snafu(display("Given invalid URI '{}': {}", input_source, source))] @@ -236,7 +248,38 @@ mod error { input_source: String, source: url::ParseError, }, + + #[snafu(display("Resolver failed: {}", source))] + ResolverFailure { + #[snafu(source(from(crate::uri_resolver::ResolverError, Box::new)))] + source: Box, + }, } } pub use error::Error; pub type Result = std::result::Result; + +#[cfg(test)] +mod resolver_selection_tests { + use super::select_resolver; + use crate::apply::SettingsInput; + use std::any::{Any, TypeId}; + use test_case::test_case; + + #[test_case("-", TypeId::of::(); "stdin")] + #[test_case("file:///tmp/folder", TypeId::of::(); "file")] + #[test_case("http://amazon.com", TypeId::of::(); "http")] + #[test_case("https://amazon.com", TypeId::of::(); "https")] + #[test_case("s3://mybucket/path", TypeId::of::(); "s3")] + #[test_case("secretsmanager://sec", TypeId::of::(); "secrets")] + #[test_case("ssm://param", TypeId::of::(); "ssmUri")] + #[test_case("arn:aws:ssm:::parameter/", TypeId::of::(); "ssmArn")] + #[test_case("arn:aws:secretsmanager:::secret:", TypeId::of::(); "secretsmanagerArn")] + + fn resolver_selection(input: &str, expected: std::any::TypeId) { + let settings = SettingsInput::new(input); + let resolver = select_resolver(&settings).expect("should have a resolver for this scheme"); + let any = resolver.as_ref() as &dyn Any; + assert_eq!(any.type_id(), expected); + } +} diff --git a/sources/api/apiclient/src/lib.rs b/sources/api/apiclient/src/lib.rs index e9121b3f2..e6f029066 100644 --- a/sources/api/apiclient/src/lib.rs +++ b/sources/api/apiclient/src/lib.rs @@ -27,6 +27,7 @@ pub mod reboot; pub mod report; pub mod set; pub mod update; +pub mod uri_resolver; mod error { use snafu::Snafu; diff --git a/sources/api/apiclient/src/uri_resolver.rs b/sources/api/apiclient/src/uri_resolver.rs new file mode 100644 index 000000000..ffe74d4ea --- /dev/null +++ b/sources/api/apiclient/src/uri_resolver.rs @@ -0,0 +1,966 @@ +//! Defines `UriResolver`, an async trait for fetching UTF-8 text from various URI schemes. +//! Concrete types parse and resolve a single scheme via `TryFrom` + `resolve()`: +//! - `StdinUri` for "-", `FileUri` for `file://`, `HttpUri` for `http(s)://` +//! - `S3Uri` for `s3://`, `SecretsManagerUri` for `secretsmanager://`, `SsmUri` for `ssm://` +//! +//! To add a new scheme, implement its `TryFrom` and `UriResolver::resolve()`. +use crate::apply::SettingsInput; +use async_trait::async_trait; +use aws_config; +use aws_sdk_ssm as ssm; +use reqwest::Url; +use snafu::{ensure, OptionExt, ResultExt, Snafu}; +use std::any::Any; +use std::convert::TryFrom; +use std::path::PathBuf; +use tokio::io::AsyncReadExt; +const MAX_SIZE_BYTES: u64 = 100 * 1024 * 1024; +/// Maximum allowed object size for S3 and HTTP(S) resolvers (100 MiB). + +/// Anything that can fetch itself as a UTF-8 `String`. +#[async_trait] +pub trait UriResolver: Any { + /// Fetches the contents of this URI as a `String`. + async fn resolve(&self) -> ResolverResult; +} + +// A minimal AWS ARN parser for our resolvers. +struct Arn { + service: String, + region: String, + parts: i8, +} + +impl Arn { + /// Parse an ARN of the form: + /// arn:aws:::: + fn parse(input: &str) -> ResolverResult { + use resolver_error::InvalidArnFormatSnafu; + ensure!( + input.starts_with("arn:aws:"), + InvalidArnFormatSnafu { + input_source: input.to_string(), + reason: "must start with 'arn:aws:'".to_string(), + } + ); + + let parts: Vec<&str> = input.split(':').collect(); + ensure!( + parts.len() > 4, + InvalidArnFormatSnafu { + input_source: input.to_string(), + reason: format!("expected at least 4 ':' separators, found {}", parts.len()), + } + ); + let service = parts[2]; + let region = parts[3]; + + Ok(Arn { + service: service.to_string(), + region: region.to_string(), + parts: parts.len() as i8, + }) + } +} + +/// Uri Resolver that reads from standard input. +/// +/// This resolver accepts exactly "-" as its URI and will read all of stdin +/// into a single UTF-8 string. +pub struct StdinUri; + +impl TryFrom<&SettingsInput> for StdinUri { + type Error = (); + fn try_from(input: &SettingsInput) -> std::result::Result { + if input.input == "-" { + Ok(StdinUri) + } else { + Err(()) + } + } +} + +#[async_trait] +impl UriResolver for StdinUri { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + let mut buf = String::new(); + tokio::io::stdin() + .read_to_string(&mut buf) + .await + .context(StdinReadSnafu)?; + Ok(buf) + } +} + +/// Uri Resolver that reads from a local file. +/// +/// This resolver accepts URIs of the form `file:///path/to/file` (or on Windows +/// `file://C:/path/to/file`), converts them to a `PathBuf`, and returns the +/// file’s entire contents as a UTF-8 string. +pub struct FileUri { + path: PathBuf, +} + +impl TryFrom<&SettingsInput> for FileUri { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + + let url = input.parsed_url.clone().context(FileUriSnafu { + input_source: input.input.clone(), + })?; + + ensure!( + url.scheme() == "file", + FileUriSnafu { + input_source: url.to_string() + } + ); + + let path = url.to_file_path().ok().context(FileUriSnafu { + input_source: url.to_string(), + })?; + + Ok(FileUri { path }) + } +} + +#[async_trait] +impl UriResolver for FileUri { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + tokio::fs::read_to_string(&self.path) + .await + .context(FileReadSnafu { + input_source: self.path.to_string_lossy().into_owned(), + }) + } +} + +/// Uri Resolver that fetches over HTTP(S). +/// +/// This resolver accepts URIs beginning with `http://` or `https://` and +/// performs a GET request with `reqwest`, returning the response body as a +/// UTF-8 string (erroring on non-2xx status). +pub struct HttpUri { + url: Url, +} + +impl TryFrom<&SettingsInput> for HttpUri { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + let url = input.parsed_url.clone().context(InvalidHTTPUriSnafu { + input_source: input.input.clone(), + })?; + ensure!( + url.scheme() == "http" || url.scheme() == "https", + InvalidHTTPUriSnafu { + input_source: url.to_string() + } + ); + Ok(HttpUri { url }) + } +} + +#[async_trait] +impl UriResolver for HttpUri { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + let resp = reqwest::get(self.url.clone()) + .await + .context(HttpRequestSnafu { + uri: self.url.to_string(), + })?; + + ensure!( + resp.status().is_success(), + HttpStatusSnafu { + uri: self.url.to_string(), + status: resp.status(), + } + ); + + // check content length if available + if let Some(content_length) = resp.content_length() { + ensure!( + content_length < MAX_SIZE_BYTES, + HttpObjectTooLargeSnafu { + size: content_length, + max_size: MAX_SIZE_BYTES, + uri: self.url.to_string(), + } + ); + } + + // read the body as bytes first to check size + let bytes = resp.bytes().await.context(HttpBodySnafu { + uri: self.url.to_string(), + })?; + ensure!( + bytes.len() as u64 <= MAX_SIZE_BYTES, + HttpObjectTooLargeSnafu { + size: bytes.len() as u64, + max_size: MAX_SIZE_BYTES, + uri: self.url.to_string(), + } + ); + + String::from_utf8(bytes.to_vec()).context(Utf8DecodeSnafu { + uri: self.url.to_string(), + }) + } +} + +/// Uri Resolver that fetches content from S3 +/// +/// This resolver accepts input of the form s3://bucket/key and translates this into +/// authenticated AWS S3 get requests using the standard AWS credential resolution mechanism +pub struct S3Uri { + bucket: String, + key: String, +} + +impl TryFrom<&SettingsInput> for S3Uri { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + const PREFIX: &str = "s3://"; + let uri_str = input.input.as_str(); + let remainder = uri_str.strip_prefix(PREFIX).context(S3UriSchemeSnafu { + input_source: input.input.clone(), + })?; + let mut parts = remainder.splitn(2, '/'); + let bucket = parts.next().context(S3UriMissingBucketSnafu { + input_source: input.input.clone(), + })?; + let key = parts.next().context(S3UriMissingKeySnafu { + input_source: input.input.clone(), + })?; + + Ok(S3Uri { + bucket: bucket.to_string(), + key: key.to_string(), + }) + } +} + +#[async_trait] +impl UriResolver for S3Uri { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + let cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let client = aws_sdk_s3::Client::new(&cfg); + + let head_resp = client + .head_object() + .bucket(&self.bucket) + .key(&self.key) + .send() + .await + .context(S3HeadSnafu { + bucket: self.bucket.clone(), + key: self.key.clone(), + })?; + + if let Some(size) = head_resp.content_length { + ensure!( + (size as u64) < MAX_SIZE_BYTES, + resolver_error::S3ObjectTooLargeSnafu { + size: size as u64, + max_size: MAX_SIZE_BYTES, + bucket: self.bucket.clone(), + key: self.key.clone(), + } + ); + } + + let resp = client + .get_object() + .bucket(&self.bucket) + .key(&self.key) + .send() + .await + .context(S3GetSnafu { + bucket: self.bucket.clone(), + key: self.key.clone(), + })?; + + let bytes = resp.body.collect().await.context(S3BodySnafu { + bucket: self.bucket.clone(), + key: self.key.clone(), + })?; + + String::from_utf8(bytes.to_vec()).context(Utf8DecodeSnafu { + uri: format!("s3://{}/{}", self.bucket, self.key), + }) + } +} + +/// Uri Resolver that fetches secrets from AWS Secrets Manager by ARN. +/// +/// This resolver accepts full AWS Secrets Manager ARNs of the form +/// `arn:aws:secretsmanager:region:account-id:secret:secret-id`, +/// uses the AWS SDK’s default credential and region resolution scoped to the +/// ARN’s region, and returns the `SecretString` payload of the specified secret. +pub struct SecretsManagerArn { + region: String, + full_arn: String, +} + +impl TryFrom<&SettingsInput> for SecretsManagerArn { + type Error = ResolverError; + + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + + let arn = Arn::parse(input.input.as_str())?; + ensure!( + arn.parts == 7, + InvalidArnFormatSnafu { + input_source: input.input.clone(), + reason: format!("expected 6 ':' separators (7 parts), found {}", arn.parts), + } + ); + + ensure!( + arn.service == "secretsmanager", + SecretsManagerArnSnafu { + input_source: input.input.clone() + } + ); + + Ok(SecretsManagerArn { + region: arn.region, + full_arn: input.input.to_string(), + }) + } +} + +#[async_trait::async_trait] +impl UriResolver for SecretsManagerArn { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + let cfg = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_sdk_secretsmanager::config::Region::new( + self.region.clone(), + )) + .load() + .await; + + let client = aws_sdk_secretsmanager::Client::new(&cfg); + + let resp = client + .get_secret_value() + .secret_id(self.full_arn.clone()) + .send() + .await + .context(SecretsManagerGetSnafu { + secret_id: self.full_arn.clone(), + })?; + + resp.secret_string() + .map(str::to_string) + .context(SecretsManagerStringMissingSnafu { + secret_id: self.full_arn.clone(), + }) + } +} + +/// Uri Resolver that fetches secrets from AWS Secrets Manager. +/// +/// This resolver accepts URIs of the form `secretsmanager://secret_id`, uses +/// the AWS SDK’s default credential and region resolution, and returns the +/// `SecretString` payload of the given secret. +pub struct SecretsManagerUri { + secret_id: String, +} + +impl TryFrom<&SettingsInput> for SecretsManagerUri { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + + const PREFIX: &str = "secretsmanager://"; + let uri_str = input.input.as_str(); + let remainder = uri_str + .strip_prefix(PREFIX) + .context(SecretsManagerUriSnafu { + input_source: input.input.clone(), + })?; + ensure!( + !remainder.is_empty(), + SecretsManagerUriSnafu { + input_source: input.input.clone() + } + ); + Ok(SecretsManagerUri { + secret_id: remainder.to_string(), + }) + } +} + +#[async_trait] +impl UriResolver for SecretsManagerUri { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + + let cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let client = aws_sdk_secretsmanager::Client::new(&cfg); + + let resp = client + .get_secret_value() + .secret_id(self.secret_id.clone()) + .send() + .await + .context(SecretsManagerGetSnafu { + secret_id: self.secret_id.clone(), + })?; + + resp.secret_string() + .map(str::to_string) + .context(SecretsManagerStringMissingSnafu { + secret_id: self.secret_id.clone(), + }) + } +} + +/// Uri Resolver that fetches a parameter by full SSM ARN. +/// +/// Accepts `ssm://arn:aws:ssm:::parameter/`, +/// uses default AWS SDK credential chain (with region override and +/// and returns the decrypted value. +pub struct SsmArn { + region: String, + full_arn: String, +} + +impl TryFrom<&SettingsInput> for SsmArn { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + + let arn = Arn::parse(input.input.as_str())?; + + ensure!( + arn.parts == 6, + InvalidArnFormatSnafu { + input_source: input.input.clone(), + reason: format!("expected 5 ':' separators (6 parts), found {}", arn.parts), + } + ); + + ensure!( + arn.service == "ssm", + SsmArnSnafu { + input_source: input.input.clone() + } + ); + + Ok(SsmArn { + region: arn.region, + full_arn: input.input.to_string(), + }) + } +} + +#[async_trait] +impl UriResolver for SsmArn { + async fn resolve(&self) -> ResolverResult { + use resolver_error::*; + + let cfg = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_sdk_ssm::config::Region::new(self.region.clone())) + .load() + .await; + + let client = aws_sdk_ssm::Client::new(&cfg); + + let resp = client + .get_parameter() + .name(self.full_arn.clone()) + .with_decryption(true) + .send() + .await + .context(SsmGetParameterSnafu { + parameter_name: self.full_arn.clone(), + })?; + + let value = resp + .parameter + .and_then(|p| p.value().map(|v| v.to_string())) + .context(SsmParameterMissingSnafu { + parameter_name: self.full_arn.clone(), + })?; + + Ok(value) + } +} + +/// Uri Resolver that fetches parameters from AWS SSM Parameter Store. +/// +/// This resolver accepts URIs of the form `ssm://parameter_name`, uses the +/// AWS SDK’s default credential and region resolution, and returns the value +/// of the requested parameter. +pub struct SsmUri { + parameter_name: String, +} + +impl TryFrom<&SettingsInput> for SsmUri { + type Error = ResolverError; + fn try_from(input: &SettingsInput) -> ResolverResult { + use resolver_error::*; + + const PREFIX: &str = "ssm://"; + let uri_str = input.input.as_str(); + let remainder = uri_str.strip_prefix(PREFIX).context(SsmUriSnafu { + input_source: input.input.clone(), + })?; + ensure!( + !remainder.is_empty(), + SsmUriSnafu { + input_source: input.input.clone() + } + ); + + Ok(SsmUri { + parameter_name: remainder.to_string(), + }) + } +} + +#[async_trait] +impl UriResolver for SsmUri { + async fn resolve(&self) -> ResolverResult { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let client = ssm::Client::new(&config); + use resolver_error::*; + + let resp = client + .get_parameter() + .name(self.parameter_name.clone()) + .with_decryption(true) + .send() + .await + .context(SsmGetParameterSnafu { + parameter_name: self.parameter_name.clone(), + })?; + + let value = resp + .parameter + .and_then(|p| p.value().map(|v| v.to_string())) + .context(SsmParameterMissingSnafu { + parameter_name: self.parameter_name.clone(), + })?; + + Ok(value) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ResolverError { + //Arn + #[snafu(display("Invalid ARN '{}': {}", input_source, reason))] + InvalidArnFormat { + input_source: String, + reason: String, + }, + + //Stdin + #[snafu(display("Failed to read standard input: {}", source))] + StdinRead { source: std::io::Error }, + + //File + #[snafu(display("Given invalid file URI '{}'", input_source))] + FileUri { input_source: String }, + + #[snafu(display("Failed to read given file '{}': {}", input_source, source))] + FileRead { + input_source: String, + source: std::io::Error, + }, + + //HTTP(S) + #[snafu(display("Given invalid HTTP(S) URI '{}'", input_source))] + InvalidHTTPUri { input_source: String }, + + #[snafu(display( + "HTTP object at {uri} is too large ({size} bytes, maximum is {max_size} bytes)" + ))] + HttpObjectTooLarge { + size: u64, + max_size: u64, + uri: String, + }, + + #[snafu(display("Failed to perform HTTP GET to '{}': {}", uri, source))] + HttpRequest { uri: String, source: reqwest::Error }, + + #[snafu(display("Non-success HTTP status from '{}': {}", uri, status))] + HttpStatus { + uri: String, + status: reqwest::StatusCode, + }, + + #[snafu(display("Failed to read HTTP response body from '{}': {}", uri, source))] + HttpBody { uri: String, source: reqwest::Error }, + + //S3 + #[snafu(display("Failed to HEAD S3 object s3://{bucket}/{key}"))] + S3Head { + source: aws_sdk_s3::error::SdkError< + aws_sdk_s3::operation::head_object::HeadObjectError, + aws_sdk_s3::config::http::HttpResponse, + >, + bucket: String, + key: String, + }, + + #[snafu(display( + "S3 object s3://{bucket}/{key} is too large ({size} bytes, maximum is {max_size} bytes)" + ))] + S3ObjectTooLarge { + size: u64, + max_size: u64, + bucket: String, + key: String, + }, + + #[snafu(display("Invalid S3 URI scheme for '{}', expected s3://", input_source))] + S3UriScheme { input_source: String }, + + #[snafu(display("Invalid S3 URI '{}': missing bucket name", input_source))] + S3UriMissingBucket { input_source: String }, + + #[snafu(display("Invalid S3 URI '{}': missing key name", input_source))] + S3UriMissingKey { input_source: String }, + + #[snafu(display("Failed to fetch S3 object '{bucket}/{key}': {}", source))] + S3Get { + bucket: String, + key: String, + source: aws_sdk_s3::error::SdkError< + aws_sdk_s3::operation::get_object::GetObjectError, + aws_sdk_s3::config::http::HttpResponse, + >, + }, + + #[snafu(display("Failed to read S3 object body '{bucket}/{key}': {}", source))] + S3Body { + bucket: String, + key: String, + source: aws_sdk_s3::primitives::ByteStreamError, + }, + + #[snafu(display("No Content-Length for S3 object {bucket}/{key}"))] + S3MissingContentLength { bucket: String, key: String }, + + //Secrets Manager + #[snafu(display( + "Invalid Secrets Manager URI scheme for '{}', expected secretsmanager://", + input_source + ))] + SecretsManagerUri { input_source: String }, + + #[snafu(display( + "Failed to fetch secret '{}' from Secrets Manager: {}", + secret_id, + source + ))] + SecretsManagerGet { + secret_id: String, + source: aws_sdk_secretsmanager::error::SdkError< + aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueError, + >, + }, + + #[snafu(display("Secrets Manager secret '{}' did not return a string value", secret_id))] + SecretsManagerStringMissing { secret_id: String }, + + #[snafu(display( + "Invalid Secrets Manager ARN scheme for '{}', expected arn:aws:secretsmanager:…", + input_source + ))] + SecretsManagerArn { input_source: String }, + + //SSM + #[snafu(display( + "Invalid SSM ARN scheme for '{}', expected arn:aws:ssm:…", + input_source + ))] + SsmArn { input_source: String }, + + #[snafu(display("Failed to fetch parameter '{}' from SSM ARN", parameter_name))] + SsmArnGetParameter { + parameter_name: String, + source: + aws_sdk_ssm::error::SdkError, + }, + #[snafu(display("SSM ARN parameter '{}' did not return a string value", parameter_name))] + SsmArnValueMissing { parameter_name: String }, + + #[snafu(display("Invalid SSM URI scheme for '{}', expected ssm://", input_source))] + SsmUri { input_source: String }, + + #[snafu(display("Failed to fetch parameter '{}' from SSM: {}", parameter_name, source))] + SsmGetParameter { + parameter_name: String, + source: + aws_sdk_ssm::error::SdkError, + }, + + #[snafu(display("SSM parameter '{}' did not return a string value", parameter_name))] + SsmParameterMissing { parameter_name: String }, + + //UTF8Decode + #[snafu(display("Failed to decode HTTP response as UTF-8 for {uri}"))] + Utf8Decode { + source: std::string::FromUtf8Error, + uri: String, + }, +} +pub type ResolverResult = std::result::Result; + +#[cfg(test)] +mod tests { + use super::{FileUri, UriResolver}; + use std::io::Write; + use std::path::PathBuf; + use tempfile::NamedTempFile; + + /// Verify that FileUri::resolve() reads the full contents of a real file. + #[tokio::test(flavor = "multi_thread")] + async fn file_uri_reads_file_content() -> Result<(), Box> { + // 1) Create a temp file and write some content + let mut tmp = NamedTempFile::new()?; + write!(tmp, "test, tempfile!")?; + + // 2) Build a FileUri pointing at that path + let path: PathBuf = tmp.path().into(); + let file_uri = FileUri { path: path.clone() }; + + // 3) Resolve and assert we get back exactly what we wrote + let result = file_uri.resolve().await?; + assert_eq!(result, "test, tempfile!"); + + Ok(()) + } +} + +#[cfg(test)] +mod parse_uri_tests { + use super::{ + FileUri, HttpUri, S3Uri, SecretsManagerArn, SecretsManagerUri, SsmArn, SsmUri, StdinUri, + }; + use crate::apply::SettingsInput; + use std::convert::TryFrom; + use test_case::test_case; + + //StdinUri + #[test_case("-"; "stdin_ok")] + fn parse_stdin(input: &str) { + let settings = SettingsInput::new(input); + let uri = StdinUri::try_from(&settings).expect("should parse stdin"); + let _ = uri; + } + + //StdinUri negative cases + #[test_case(""; "empty_input")] + #[test_case(" -"; "leading_space")] + #[test_case("--"; "double_dash")] + fn parse_stdin_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + StdinUri::try_from(&settings).is_err(), + "only `-` should parse as stdin" + ); + } + + //FileUri + #[test_case("file:///tmp/foo", "/tmp/foo"; "file_ok")] + fn parse_file(input: &str, expected_path: &str) { + let settings = SettingsInput::new(input); + let uri = FileUri::try_from(&settings).expect("should parse file URI"); + assert_eq!( + uri.path.to_str().unwrap(), + expected_path, + "file:// path must match" + ); + } + + //FileUri negative cases + #[test_case("file_:/"; "weird_path")] + #[test_case("file://no/leading/slash"; "no_leading_slash")] + fn parse_file_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + FileUri::try_from(&settings).is_err(), + "invalid file URI should fail" + ); + } + + //HttpUri + #[test_case("http://example.com/foo", "http://example.com/foo"; "http_ok")] + #[test_case("https://example.com/bar", "https://example.com/bar"; "https_ok")] + fn parse_http(input: &str, expected: &str) { + let settings = SettingsInput::new(input); + let uri = HttpUri::try_from(&settings).expect("should parse HTTP URI"); + assert_eq!(uri.url.as_str(), expected, "HTTP URI must round‑trip"); + } + + //HttpUri negative cases + #[test_case("ftp://example.com"; "unsupported_scheme")] + #[test_case("http://"; "empty_authority")] + #[test_case("https:// "; "space_after_scheme")] + fn parse_http_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + HttpUri::try_from(&settings).is_err(), + "invalid HTTP URI should fail" + ); + } + + //S3Uri + #[test_case("s3://bucket/key", "bucket", "key"; "s3_ok")] + fn parse_s3(input: &str, exp_bucket: &str, exp_key: &str) { + let settings = SettingsInput::new(input); + let uri = S3Uri::try_from(&settings).expect("should parse S3 URI"); + assert_eq!(uri.bucket, exp_bucket, "S3 bucket"); + assert_eq!(uri.key, exp_key, "S3 key"); + } + + //S3Uri negative cases + #[test_case("s3://bucket"; "missing_key")] + #[test_case("s3:/bucket/key"; "malformed_scheme")] + #[test_case("s3://"; "empty_bucket_and_key")] + fn parse_s3_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + S3Uri::try_from(&settings).is_err(), + "invalid S3 URI should fail" + ); + } + + // SecretsManagerArn + #[test_case( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:mysecret", + "us-east-1", "arn:aws:secretsmanager:us-east-1:111122223333:secret:mysecret"; + "secretsmanager_arn_ok" + )] + fn parse_secretsmanager_arn(input: &str, exp_region: &str, exp_id: &str) { + let settings = SettingsInput::new(input); + let uri = SecretsManagerArn::try_from(&settings).expect("should parse SecretsManager ARN"); + assert_eq!(uri.region, exp_region, "SecretsManager ARN region"); + assert_eq!(uri.full_arn, exp_id, "SecretsManager ARN secret id"); + } + + //SecretsManagerArn negative case + #[test_case( + "arn:aws:ssm:us-west-2:123456789012:parameter/myparam"; + "ssm_arn_not_secretsmanager" + )] + fn parse_secretsmanager_arn_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + SecretsManagerArn::try_from(&settings).is_err(), + "SSM ARN should not parse as SecretsManagerArn" + ); + } + + //SecretsManagerUri + #[test_case("secretsmanager://mysecret", "mysecret"; "secrets_ok")] + fn parse_secrets(input: &str, exp_id: &str) { + let settings = SettingsInput::new(input); + let uri = SecretsManagerUri::try_from(&settings).expect("should parse SecretsManager URI"); + assert_eq!(uri.secret_id, exp_id, "secret_id"); + } + + //SecretsManagerUri negative cases + #[test_case("secretsmanager:/mysecret"; "missing_double_slash")] + #[test_case("secretsmanager://"; "empty_secret_id")] + #[test_case("ssm://mysecret"; "wrong_scheme")] + #[test_case("arn:aws:secretsmanager:us-east-1:111122223333:secret:foo"; "arn_not_uri")] + fn parse_secrets_uri_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + SecretsManagerUri::try_from(&settings).is_err(), + "invalid SecretsManager URI should fail" + ); + } + + // SsmArn + #[test_case( + "arn:aws:ssm:us-west-2:123456789012:parameter/myparam", + "us-west-2", "arn:aws:ssm:us-west-2:123456789012:parameter/myparam"; + "ssm_arn_ok" + )] + fn parse_ssm_arn(input: &str, exp_region: &str, exp_param: &str) { + let settings = SettingsInput::new(input); + let uri = SsmArn::try_from(&settings).expect("should parse SSM ARN"); + assert_eq!(uri.region, exp_region, "SSM ARN region"); + assert_eq!(uri.full_arn, exp_param, "SSM ARN parameter"); + } + + //SsmArn negative case + #[test_case( + "arn:aws:secretsmanager:us-east-1:111122223333:secret:mysecret"; + "secretsmanager_arn_not_ssm" + )] + fn parse_ssm_arn_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + SsmArn::try_from(&settings).is_err(), + "invalid SSM ARN should fail" + ); + } + + //SsmUri + #[test_case("ssm://parameter", "parameter"; "ssm_ok")] + fn parse_ssm(input: &str, exp_param: &str) { + let settings = SettingsInput::new(input); + let uri = SsmUri::try_from(&settings).expect("should parse SSM URI"); + assert_eq!(uri.parameter_name, exp_param, "parameter name"); + } + + //SsmUri negative cases + #[test_case("ssm:/parameter"; "missing_double_slash")] + #[test_case("ssm://"; "empty_parameter")] + #[test_case("secretsmanager://parameter"; "wrong_scheme")] + #[test_case("arn:aws:ssm:us-west-2:123:parameter/myparam"; "arn_not_uri")] + fn parse_ssm_uri_fail(input: &str) { + let settings = SettingsInput::new(input); + assert!( + SsmUri::try_from(&settings).is_err(), + "invalid SSM URI should fail" + ); + } +} + +#[cfg(test)] +mod s3_uri_tests { + use super::S3Uri; + use crate::apply::SettingsInput; + use std::convert::TryFrom; + use test_case::test_case; + #[test_case("s3://testbucket/🚀⚡️.json", "testbucket", "🚀⚡️.json"; "s3_emoji")] + #[test_case("s3://testbucket/#hashstart.json", "testbucket", "#hashstart.json"; "hash_start")] + #[test_case("s3://testbucket/@atstart.json", "testbucket", "@atstart.json"; "at_start")] + #[test_case("s3://testbucket/'singlequotes'.json", "testbucket", "'singlequotes'.json"; "single_quotes")] + #[test_case("s3://testbucket/$dollarstart.json", "testbucket", "$dollarstart.json"; "dollar_start")] + #[test_case("s3://testbucket/doublejson.json.json", "testbucket", "doublejson.json.json"; "double_dot_json")] + #[test_case("s3://testbucket/file with spaces.json", "testbucket", "file with spaces.json"; "key_with_spaces")] + #[test_case("s3://testbucket/file@#$%^&*.json", "testbucket", "file@#$%^&*.json"; "symbols")] + #[test_case("s3://testbucket/fileñ.json", "testbucket", "fileñ.json"; "n_tilde")] + #[test_case("s3://testbucket/filepunc,;:`.json", "testbucket", "filepunc,;:`.json"; "punctuation")] + #[test_case("s3://testbucket/?question?marks?.json", "testbucket", "?question?marks?.json"; "question_marks")] + #[test_case("s3://testbucket/fileü漢字.json", "testbucket", "fileü漢字.json"; "other_language")] + + fn parse_s3(input: &str, exp_bucket: &str, exp_key: &str) { + let settings = SettingsInput::new(input); + let uri = S3Uri::try_from(&settings).expect("should parse S3 URI"); + assert_eq!(uri.bucket, exp_bucket, "S3 bucket"); + assert_eq!(uri.key, exp_key, "S3 key"); + } +} diff --git a/sources/clarify.toml b/sources/clarify.toml index 5b7a357d6..afe758172 100644 --- a/sources/clarify.toml +++ b/sources/clarify.toml @@ -26,6 +26,13 @@ license-files = [ { path = "LICENSE-MIT", hash = 0xfeb1e4a7 }, ] +[clarify.aws-sdk-s3] +expression = "Apache-2.0" +license-files = [ + { path = "LICENSE", hash = 0x1b71bb24 } +] +skip-files = [ "tests/blns/LICENSE" ] # we do not vend this as it's just used in s3's tests + [clarify.backtrace-sys] # backtrace-sys is MIT/Apache-2.0, libbacktrace is BSD-3-Clause expression = "(MIT OR Apache-2.0) AND BSD-3-Clause" diff --git a/sources/deny.toml b/sources/deny.toml index 813ad50f4..1e34e82a3 100644 --- a/sources/deny.toml +++ b/sources/deny.toml @@ -51,6 +51,9 @@ deny = [{ name = "structopt" }, { name = "clap", wrappers = ["cargo-readme"] }] skip = [ # this can be removed once settings sdk is updated to base64 0.22.1 { name = "base64", version = "=0.21.7" }, + + # `aws-sigv4 v1.2.6` depends on both `crypto-bigint v0.5.5` and `crypto-bigint v0.4.9` + { name = "crypto-bigint", version = "=0.4.9" }, ] skip-tree = [