#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use aws_sdk_s3::config::timeout::TimeoutConfig;
use dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink, Watching};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::documents::{self, Overlap, MOST_KEYS};
use dynamic_config_store_core::guarded;
pub use aws_config::SdkConfig;
pub use aws_sdk_s3::Client;
use dynamic_config_store_core::tls as tls_core;
pub use dynamic_config_store_core::tls::TlsConfig;
use rustls_pki_types::pem::PemObject;
use aws_sdk_s3::error::ProvideErrorMetadata;
const AUTH_CODES: [&str; 6] = [
"AccessDenied",
"InvalidAccessKeyId",
"SignatureDoesNotMatch",
"ExpiredToken",
"InvalidToken",
"TokenRefreshRequired",
];
const MOST_LIST_PAGES: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Keys {
One(String),
Several(Vec<String>),
Prefix(String),
}
impl Keys {
#[must_use]
pub fn one(key: impl Into<String>) -> Self {
Self::One(key.into())
}
#[must_use]
pub fn several<I, S>(keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Several(keys.into_iter().map(Into::into).collect())
}
#[must_use]
pub fn prefix(prefix: impl Into<String>) -> Self {
Self::Prefix(prefix.into())
}
fn named(&self) -> &[String] {
match self {
Self::One(key) => std::slice::from_ref(key),
Self::Several(keys) => keys,
Self::Prefix(_) => &[],
}
}
fn describe(&self) -> String {
match self {
Self::One(key) => key.clone(),
Self::Several(keys) => format!("keys {}", keys.join(", ")),
Self::Prefix(prefix) => format!("prefix {prefix}"),
}
}
}
impl From<&str> for Keys {
fn from(key: &str) -> Self {
Self::one(key)
}
}
impl From<String> for Keys {
fn from(key: String) -> Self {
Self::One(key)
}
}
impl From<&String> for Keys {
fn from(key: &String) -> Self {
Self::one(key)
}
}
pub struct S3 {
client: Client,
bucket: String,
keys: Keys,
format: Option<Format>,
disagreement: Option<String>,
endpoint: Option<String>,
attempts: Attempts,
}
impl S3 {
pub async fn new(bucket: impl Into<String>, key: impl Into<Keys>) -> Result<Self, Error> {
let config = aws_config::load_from_env().await;
Ok(Self::with_config(&config, bucket, key))
}
#[must_use]
pub fn with_config(
config: &SdkConfig,
bucket: impl Into<String>,
key: impl Into<Keys>,
) -> Self {
let s3 = aws_sdk_s3::config::Builder::from(config)
.force_path_style(true)
.build();
let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
source.endpoint = config.endpoint_url().map(str::to_owned);
source
}
pub fn with_tls(
config: &SdkConfig,
bucket: impl Into<String>,
key: impl Into<Keys>,
tls: &TlsConfig,
) -> Result<Self, Error> {
let bucket = bucket.into();
let described = format!("s3 {bucket}");
if tls.client_certificate().is_some() {
return Err(tls_core::unsupported(
&described,
"a client certificate",
"the AWS SDK's TLS context has a trust store and no \
client-certificate slot; build the connector yourself and use \
`from_client`",
));
}
let mut trust_store = aws_smithy_http_client::tls::TrustStore::empty();
if let Some(pem) = tls.ca_certificate_pem(&described)? {
let readable = rustls_pki_types::CertificateDer::pem_slice_iter(&pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
Error::remote(format!(
"{described}: the CA certificate is not PEM-encoded \
certificate material"
))
})?;
if readable.is_empty() {
return Err(Error::remote(format!(
"{described}: the CA certificate holds no certificate; it \
is refused rather than ignored"
)));
}
trust_store = trust_store.with_pem_certificate(pem);
}
let context = aws_smithy_http_client::tls::TlsContext::builder()
.with_trust_store(trust_store)
.build()
.map_err(|_| {
Error::remote(format!(
"{described}: the CA certificate was refused; check that it \
is PEM-encoded certificate material"
))
})?;
let http = aws_smithy_http_client::Builder::new()
.tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
aws_smithy_http_client::tls::rustls_provider::CryptoMode::AwsLc,
))
.tls_context(context)
.build_https();
let s3 = aws_sdk_s3::config::Builder::from(config)
.force_path_style(true)
.http_client(http)
.build();
let mut source = Self::from_client(Client::from_conf(s3), bucket, key);
source.endpoint = config.endpoint_url().map(str::to_owned);
Ok(source)
}
#[must_use]
pub fn from_client(client: Client, bucket: impl Into<String>, key: impl Into<Keys>) -> Self {
let keys = key.into();
let (format, disagreement) = match documents::agreed_format(keys.named()) {
Ok(format) => (format, None),
Err(complaint) => (None, Some(complaint)),
};
Self {
client,
bucket: bucket.into(),
keys,
format,
disagreement,
endpoint: None,
attempts: Attempts::default(),
}
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self.disagreement = None;
self
}
#[must_use]
pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
self.attempts = Attempts::to(sink);
self
}
fn format(&self) -> Result<Format, Error> {
if let Some(complaint) = &self.disagreement {
return Err(Error::remote(format!("{}: {complaint}", self.describe())));
}
self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})
}
fn single_key(&self) -> Result<&str, Error> {
match &self.keys {
Keys::One(key) => Ok(key),
_ => Err(Error::remote(format!(
"{}: a source that reads several keys cannot be watched; \
poll `refresh_remote_async()` on a timer instead",
self.describe()
))),
}
}
fn overlap(&self) -> Overlap {
match self.keys {
Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
Keys::Prefix(_) => Overlap::Refused,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
let timeouts = self
.client
.config()
.timeout_config()
.map_or_else(TimeoutConfig::builder, TimeoutConfig::to_builder)
.operation_attempt_timeout(timeout)
.build();
let config = self
.client
.config()
.to_builder()
.timeout_config(timeouts)
.build();
self.client = Client::from_conf(config);
self
}
pub async fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error> + Send,
{
self.format()?;
self.single_key()?;
let mut seen: Option<String> = None;
while watching.keep_going() {
match self.etag().await {
Ok(tag) if seen.is_none() => seen = Some(tag),
Ok(tag) if seen.as_ref() != Some(&tag) => {
match self.read().await {
Ok((document, current)) => {
seen = current.or(Some(tag));
guarded(&mut on_change, document, &self.describe())?;
}
Err(error) => self.attempts.failed(&error),
}
}
Ok(_) => {}
Err(error) => self.attempts.failed(&error),
}
sleep_while(interval, watching).await;
}
Ok(())
}
fn classified<E: ProvideErrorMetadata + std::fmt::Display>(&self, error: &E) -> Error {
let described = format!("{}: {error}", self.describe());
match error.code() {
Some(code) if AUTH_CODES.contains(&code) => Error::auth(described),
_ => Error::remote(described),
}
}
async fn etag(&self) -> Result<String, Error> {
let key = self.single_key()?;
let head = self
.client
.head_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|error| self.classified(&error))?;
head.e_tag()
.map(str::to_owned)
.ok_or_else(|| Error::remote(format!("{}: the object has no ETag", self.describe())))
}
async fn read(&self) -> Result<(Fetched, Option<String>), Error> {
let format = self.format()?;
let key = self.single_key()?;
let (text, tag) = self.object(key).await?;
Ok((Fetched::new(text, format), tag))
}
async fn object(&self, key: &str) -> Result<(String, Option<String>), Error> {
let object = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|error| self.classified(&error))?;
let tag = object.e_tag().map(str::to_owned);
let bytes = object
.body
.collect()
.await
.map_err(|error| Error::remote(format!("{}: `{key}`: {error}", self.describe())))?
.into_bytes();
let text = String::from_utf8(bytes.to_vec()).map_err(|error| {
Error::remote(format!(
"{}: `{key}` is not UTF-8: {error}",
self.describe()
))
})?;
Ok((text, tag))
}
async fn documents(&self) -> Result<Vec<(String, String)>, Error> {
let keys = match &self.keys {
Keys::One(key) => vec![key.clone()],
Keys::Several(keys) => keys.clone(),
Keys::Prefix(prefix) => self.listed(prefix).await?,
};
if keys.is_empty() {
return Err(Error::remote(format!(
"{}: nothing matched, so there is nothing to load",
self.describe()
)));
}
let mut documents = Vec::with_capacity(keys.len());
for key in keys {
let (text, _tag) = self.object(&key).await?;
documents.push((key, text));
}
Ok(documents)
}
async fn listed(&self, prefix: &str) -> Result<Vec<String>, Error> {
let per_page = i32::try_from(MOST_KEYS + 1).unwrap_or(i32::MAX);
let mut found: Vec<String> = Vec::new();
let mut token: Option<String> = None;
for _ in 0..MOST_LIST_PAGES {
let page = self
.client
.list_objects_v2()
.bucket(&self.bucket)
.prefix(prefix)
.max_keys(per_page)
.set_continuation_token(token)
.send()
.await
.map_err(|error| self.classified(&error))?;
for object in page.contents() {
let Some(key) = object.key() else {
continue;
};
documents::under_prefix(key, prefix, &self.describe())?;
if key.ends_with('/') {
continue;
}
found.push(key.to_owned());
}
documents::within_key_budget(found.len(), &self.describe())?;
token = page.next_continuation_token().map(str::to_owned);
if token.is_none() {
return Ok(found);
}
}
Err(Error::remote(format!(
"{}: the listing did not finish in {MOST_LIST_PAGES} pages; \
the store is not advancing the continuation token",
self.describe()
)))
}
}
impl AsyncRemoteSource for S3 {
fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
Box::pin(async move {
let format = self.format()?;
let documents = self.documents().await?;
documents::merged(&documents, format, self.overlap(), &self.describe())
})
}
fn describe(&self) -> String {
match &self.endpoint {
Some(endpoint) => format!("s3 {endpoint} {}/{}", self.bucket, self.keys.describe()),
None => format!("s3 {}/{}", self.bucket, self.keys.describe()),
}
}
}
impl std::fmt::Debug for S3 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("S3")
.field("bucket", &self.bucket)
.field("keys", &self.keys)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
async fn sleep_while(total: Duration, watching: &Watching) {
const SLICE: Duration = Duration::from_millis(250);
let mut slept = Duration::ZERO;
while slept < total && watching.keep_going() {
tokio::time::sleep(SLICE.min(total - slept)).await;
slept += SLICE;
}
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use super::*;
fn against(endpoint: &str, retries: RetryConfig) -> S3 {
let config = aws_sdk_s3::config::Builder::new()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(retries)
.credentials_provider(Credentials::for_tests())
.build();
S3::from_client(Client::from_conf(config), "myapp-config", "prod/db.json")
}
fn scripted(
status: &'static str,
body: impl Into<String>,
) -> (
String,
std::sync::Arc<std::sync::atomic::AtomicUsize>,
std::thread::JoinHandle<()>,
) {
use std::sync::atomic::{AtomicUsize, Ordering};
let body = body.into();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let requests = std::sync::Arc::new(AtomicUsize::new(0));
let counter = std::sync::Arc::clone(&requests);
let server = std::thread::spawn(move || {
for _ in 0..MOST_LIST_PAGES + 4 {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut seen = Vec::new();
let mut byte = [0u8; 1];
while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
seen.push(byte[0]);
}
counter.fetch_add(1, Ordering::SeqCst);
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: application/xml\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
}
});
(endpoint, requests, server)
}
fn listing(keys: &[String], token: Option<&str>) -> String {
let contents: String = keys
.iter()
.map(|key| format!("<Contents><Key>{key}</Key><Size>1</Size></Contents>"))
.collect();
let truncation = match token {
Some(token) => format!(
"<IsTruncated>true</IsTruncated><NextContinuationToken>{token}</NextContinuationToken>"
),
None => "<IsTruncated>false</IsTruncated>".to_owned(),
};
format!(
r#"<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>myapp-config</Name>{truncation}{contents}</ListBucketResult>"#
)
}
fn reading(endpoint: &str, keys: Keys) -> S3 {
let config = aws_sdk_s3::config::Builder::new()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.credentials_provider(Credentials::for_tests())
.build();
S3::from_client(Client::from_conf(config), "myapp-config", keys).with_format(Format::Json)
}
#[tokio::test]
async fn a_prefix_over_the_budget_is_refused_after_one_listing() {
let keys: Vec<String> = (0..=MOST_KEYS)
.map(|n| format!("prod/section-{n:04}.json"))
.collect();
let (endpoint, requests, server) = scripted("200 OK", listing(&keys, None));
let error = reading(&endpoint, Keys::prefix("prod/"))
.fetch()
.await
.expect_err("the prefix matches more keys than the budget allows");
drop(server);
assert!(error.to_string().contains("narrow the prefix"), "{error}");
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
1,
"the budget is checked on the listing, so not one body is fetched"
);
}
#[tokio::test]
async fn a_key_the_store_answers_with_from_outside_the_prefix_is_refused() {
let (endpoint, _requests, server) = scripted(
"200 OK",
listing(&["other-tenant/db.json".to_owned()], None),
);
let error = reading(&endpoint, Keys::prefix("prod/"))
.fetch()
.await
.expect_err("that key is not under the prefix that was asked for");
drop(server);
assert!(
error.to_string().contains("other-tenant/db.json"),
"{error}"
);
assert!(
error.to_string().contains("not under the prefix"),
"{error}"
);
}
#[tokio::test]
async fn a_listing_that_never_finishes_is_given_up_on() {
let (endpoint, requests, server) = scripted("200 OK", listing(&[], Some("always-more")));
let error = reading(&endpoint, Keys::prefix("prod/"))
.fetch()
.await
.expect_err("the token never clears");
drop(server);
assert!(
error.to_string().contains("not advancing the continuation"),
"{error}"
);
assert_eq!(
requests.load(std::sync::atomic::Ordering::SeqCst),
MOST_LIST_PAGES,
"the page budget is what ends it"
);
}
#[tokio::test]
async fn a_multi_key_source_refuses_to_be_watched_and_says_what_to_do_instead() {
let source = reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"]));
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let error = source
.watch(&watching, Duration::from_millis(50), |_| Ok(()))
.await
.expect_err("a merged document has no one ETag");
assert!(error.to_string().contains("several keys"), "{error}");
assert!(
error.to_string().contains("refresh_remote_async"),
"{error}"
);
}
#[tokio::test]
async fn keys_naming_two_formats_are_reported_rather_than_guessed() {
let config = aws_sdk_s3::config::Builder::new()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("us-east-1"))
.endpoint_url("http://127.0.0.1:9")
.force_path_style(true)
.retry_config(RetryConfig::disabled())
.credentials_provider(Credentials::for_tests())
.build();
let source = S3::from_client(
Client::from_conf(config),
"myapp-config",
Keys::several(["prod/db.json", "prod/server.toml"]),
);
let error = source.fetch().await.expect_err("two formats, one source");
assert!(error.to_string().contains("prod/db.json"), "{error}");
assert!(error.to_string().contains("prod/server.toml"), "{error}");
assert!(error.to_string().contains("with_format"), "{error}");
}
#[tokio::test]
async fn access_denied_is_an_auth_failure() {
let (endpoint, _requests, server) = scripted(
"403 Forbidden",
r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"#,
);
let error = against(&endpoint, RetryConfig::disabled())
.fetch()
.await
.expect_err("the store refused the credentials");
drop(server);
assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth, "{error}");
assert!(error.to_string().contains("prod/db.json"), "{error}");
}
#[tokio::test]
async fn a_skewed_clock_shares_the_403_and_stays_remote() {
let (endpoint, _requests, server) = scripted(
"403 Forbidden",
r#"<?xml version="1.0" encoding="UTF-8"?><Error><Code>RequestTimeTooSkewed</Code><Message>The difference between the request time and the current time is too large.</Message></Error>"#,
);
let error = against(&endpoint, RetryConfig::disabled())
.fetch()
.await
.expect_err("the store refused the request");
drop(server);
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
}
#[tokio::test]
async fn an_unreachable_store_is_remote_rather_than_auth() {
let error = against("http://127.0.0.1:9", RetryConfig::disabled())
.with_timeout(Duration::from_millis(200))
.fetch()
.await
.expect_err("nothing is listening");
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
}
#[tokio::test]
async fn the_deadline_is_per_attempt_and_the_sdk_retries_underneath() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let silent = std::thread::spawn(move || {
let mut held = Vec::new();
while held.len() < 3 {
let Ok(accepted) = listener.accept() else {
return;
};
held.push(accepted);
}
std::thread::sleep(Duration::from_secs(1));
});
const ATTEMPT: Duration = Duration::from_millis(300);
let source = against(
&endpoint,
RetryConfig::standard()
.with_max_attempts(3)
.with_initial_backoff(Duration::from_millis(1)),
)
.with_timeout(ATTEMPT);
assert_eq!(
source
.client
.config()
.timeout_config()
.and_then(aws_sdk_s3::config::timeout::TimeoutConfig::operation_attempt_timeout),
Some(ATTEMPT),
"the value has to reach the SDK, not merely be remembered here"
);
let started = std::time::Instant::now();
let error = source.fetch().await.expect_err("nothing ever answers");
let elapsed = started.elapsed();
assert!(
elapsed > ATTEMPT * 2,
"the SDK retries beneath the per-attempt deadline, so the call \
outlasts one attempt — that is the README's multiplier: {elapsed:?}"
);
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote, "{error}");
let _ = silent.join();
}
#[tokio::test]
async fn a_failing_poll_reports_the_store_as_down_and_leaves_the_clock_running() {
use dynamic_config::dynamic_config;
#[dynamic_config]
#[derive(Debug, serde::Deserialize)]
struct Polled {
#[allow(dead_code)]
host: String,
}
let (answering, _requests, answered) = scripted("200 OK", r#"{"db": {"host": "base"}}"#);
Polled::set_remote_async(against(&answering, RetryConfig::disabled()));
Polled::refresh_remote_async()
.await
.expect("the store answers the first read");
let sink = Polled::remote_sink();
let before = sink.status();
assert_eq!(before.reachable(), Some(true), "one fetch, and it answered");
assert!(before.last_fetch.is_some());
let (refusing, _polls, refused) = scripted(
"500 Internal Server Error",
"<Error><Code>Internal</Code></Error>",
);
let watcher = against(&refusing, RetryConfig::disabled())
.with_timeout(Duration::from_millis(500))
.reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let polling = tokio::spawn(async move {
watcher
.watch(&watching, Duration::from_millis(50), |_| Ok(()))
.await
});
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
let after = sink.status();
assert!(
!polling.is_finished(),
"a failed check does not end the watch — which is exactly why \
reporting it is the only way anyone hears about it"
);
assert_eq!(
after.reachable(),
Some(false),
"a loop polling into the void is a store that is down"
);
assert_eq!(
after.last_fetch, before.last_fetch,
"the staleness clock keeps running: `last_fetch` is when a document \
last arrived, and a failed attempt is not one"
);
assert_eq!(
after.fetches, before.fetches,
"a failure is not a fetch, however it is counted elsewhere"
);
assert_eq!(
after
.last_failure
.as_ref()
.expect("a failure was recorded")
.kind,
dynamic_config::ErrorKind::Remote,
"a store answering 500 may yet come back"
);
let recorded = format!("{:?}", after.last_failure);
assert!(!recorded.contains("myapp-config"), "{recorded}");
assert!(!recorded.contains("prod/db.json"), "{recorded}");
watch.stop();
let _ = polling.await;
drop((answered, refused));
}
fn heads_but_refuses_the_body() -> (String, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let mut heads = 0;
for _ in 0..64 {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut seen = Vec::new();
let mut byte = [0u8; 1];
while !seen.ends_with(b"\r\n\r\n") && stream.read(&mut byte).is_ok_and(|n| n == 1) {
seen.push(byte[0]);
}
let response = if String::from_utf8_lossy(&seen).starts_with("HEAD") {
heads += 1;
let tag = if heads > 1 { "second" } else { "first" };
format!(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nETag: \"{tag}\"\r\nConnection: close\r\n\r\n"
)
} else {
let body = "<Error><Code>Internal</Code></Error>";
format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
};
if stream.write_all(response.as_bytes()).is_err() {
return;
}
}
});
(endpoint, server)
}
#[tokio::test]
async fn a_read_that_fails_after_the_tag_moved_is_reported_too() {
use dynamic_config::dynamic_config;
#[dynamic_config]
#[derive(Debug, serde::Deserialize)]
struct Torn {
#[allow(dead_code)]
host: String,
}
let sink = Torn::remote_sink();
let (endpoint, server) = heads_but_refuses_the_body();
let watcher = against(&endpoint, RetryConfig::disabled())
.with_timeout(Duration::from_millis(500))
.reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let polling = tokio::spawn(async move {
watcher
.watch(&watching, Duration::from_millis(50), |_| Ok(()))
.await
});
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
!polling.is_finished(),
"a failed read does not end the watch either"
);
assert_eq!(
sink.status().reachable(),
Some(false),
"the store answered the check and not the read, which is a store \
this loop cannot get a document out of"
);
assert_eq!(
sink.status().fetches,
0,
"nothing was delivered, so nothing is counted as a fetch"
);
watch.stop();
let _ = polling.await;
drop(server);
}
#[tokio::test]
async fn a_watch_refused_at_the_door_is_not_a_store_that_stopped_answering() {
use dynamic_config::dynamic_config;
#[dynamic_config]
#[derive(Debug, serde::Deserialize)]
struct Doorstep {
#[allow(dead_code)]
host: String,
}
let sink = Doorstep::remote_sink();
let source =
reading("http://127.0.0.1:9", Keys::several(["a.json", "b.json"])).reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let error = source
.watch(&watch.watching(), Duration::from_millis(50), |_| Ok(()))
.await
.expect_err("an ETag belongs to an object, and a set has none");
assert!(error.to_string().contains("cannot be watched"), "{error}");
assert_eq!(
sink.status().reachable(),
None,
"nothing has been asked of this store, so it is neither up nor down"
);
}
fn authority() -> String {
use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
let key = KeyPair::generate().unwrap();
let mut params = CertificateParams::new(Vec::new()).unwrap();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.self_signed(&key).unwrap().pem()
}
#[tokio::test]
async fn a_private_authority_builds_a_client_and_touches_nothing() {
let config = aws_config::SdkConfig::builder()
.behavior_version(aws_config::BehaviorVersion::latest())
.endpoint_url("https://minio.internal:9000")
.build();
let source = S3::with_tls(
&config,
"myapp-config",
"prod/db.json",
&TlsConfig::new().with_ca_certificate_pem(authority()),
)
.expect("a trust store is what the SDK's TLS context holds");
assert!(
source.describe().contains("minio.internal"),
"the endpoint tells MinIO apart from AWS in an error: {}",
source.describe()
);
}
#[tokio::test]
async fn a_ca_certificate_the_sdk_would_panic_on_is_refused_at_construction() {
let config = aws_config::SdkConfig::builder()
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let error = S3::with_tls(
&config,
"myapp-config",
"prod/db.json",
&TlsConfig::new().with_ca_certificate_pem(
"-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n",
),
)
.expect_err("the SDK would have panicked on this");
assert!(error.to_string().contains("not PEM-encoded"), "{error}");
}
#[tokio::test]
async fn a_client_certificate_is_refused_and_points_at_the_escape_hatch() {
let config = aws_config::SdkConfig::builder()
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let error = S3::with_tls(
&config,
"myapp-config",
"prod/db.json",
&TlsConfig::new().with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
)
.expect_err("the SDK's TLS context has no client-certificate slot");
assert!(error.to_string().contains("client certificate"), "{error}");
assert!(error.to_string().contains("from_client"), "{error}");
assert!(
error.to_string().contains("refused rather than ignored"),
"{error}"
);
}
#[tokio::test]
async fn the_client_certificate_refusal_never_quotes_the_key() {
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
let config = aws_config::SdkConfig::builder()
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let error = S3::with_tls(
&config,
"myapp-config",
"prod/db.json",
&TlsConfig::new().with_client_certificate_pem("cert", PLANTED),
)
.expect_err("the SDK's TLS context has no client-certificate slot");
assert!(!error.to_string().contains(PLANTED), "{error}");
assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
}
#[tokio::test]
async fn a_missing_ca_file_names_the_path_and_the_material() {
let config = aws_config::SdkConfig::builder()
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let error = S3::with_tls(
&config,
"myapp-config",
"prod/db.json",
&TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem"),
)
.expect_err("the CA file is not there");
assert!(
error.to_string().contains("/nonexistent/private-ca.pem"),
"{error}"
);
assert!(error.to_string().contains("the CA certificate"), "{error}");
}
}