#![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 dynamic_config::{AsyncRemoteSource, Error, Fetched, Format, RemoteSink};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::guarded;
use etcd_client::EventType;
use tokio::sync::Mutex;
pub use etcd_client::{Client, ConnectOptions};
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub use etcd_client::{Certificate, Identity, TlsOptions};
pub use dynamic_config_store_core::tls::TlsConfig;
const INVALID_TOKEN: &str = "invalid auth token";
const AUTH_REFUSALS: [&str; 5] = [
INVALID_TOKEN,
"authentication failed",
"permission denied",
"user name is empty",
"user name not found",
];
const MOST_TRANSACTION_KEYS: usize = 128;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[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) => format!("key {key}"),
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 Etcd {
client: Mutex<Client>,
keys: Keys,
format: Option<Format>,
disagreement: Option<String>,
endpoints: String,
timeout: Duration,
attempts: Attempts,
}
impl Etcd {
pub async fn new<E, S>(endpoints: E, keys: impl Into<Keys>) -> Result<Self, Error>
where
E: IntoIterator<Item = S>,
S: Into<String>,
{
Self::with_options(endpoints, keys, ConnectOptions::new()).await
}
pub async fn with_options<E, S>(
endpoints: E,
keys: impl Into<Keys>,
options: ConnectOptions,
) -> Result<Self, Error>
where
E: IntoIterator<Item = S>,
S: Into<String>,
{
let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
let described = endpoints.join(", ");
let client = connect(&endpoints, &options, &described).await?;
Ok(Self::build(client, keys, described))
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub async fn with_tls<E, S>(
endpoints: E,
keys: impl Into<Keys>,
options: ConnectOptions,
tls: &TlsConfig,
) -> Result<Self, Error>
where
E: IntoIterator<Item = S>,
S: Into<String>,
{
let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
let described = endpoints.join(", ");
let options = options.with_tls(tls_options(tls, &format!("etcd {described}"))?);
let client = connect(&endpoints, &options, &described).await?;
Ok(Self::build(client, keys, described))
}
#[must_use]
pub fn from_client(client: Client, keys: impl Into<Keys>) -> Self {
Self::build(client, keys, "<an existing client>".to_owned())
}
fn build(client: Client, keys: impl Into<Keys>, endpoints: String) -> Self {
let keys = keys.into();
let (format, disagreement) = match documents::agreed_format(keys.named()) {
Ok(format) => (format, None),
Err(complaint) => (None, Some(complaint)),
};
Self {
client: Mutex::new(client),
keys,
format,
disagreement,
endpoints,
timeout: DEFAULT_TIMEOUT,
attempts: Attempts::default(),
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
self.attempts = Attempts::to(sink);
self
}
fn failing(&self, error: Error) -> Error {
self.attempts.failed(&error);
error
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self.disagreement = None;
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()
))
})
}
pub async fn watch<F>(&self, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error> + Send,
{
let format = self.format()?;
if let Keys::Several(_) = &self.keys {
return Err(Error::remote(format!(
"{}: a source that reads a named list of keys cannot be \
watched; etcd establishes a watch on a key or a range, so a \
list would be one stream per key and none of them would say \
the set moved together — watch a prefix, or poll \
`refresh_remote_async()` on a timer, which is one round trip",
self.describe()
)));
}
let mut stream = match self.watch_once(None).await {
Err(error) if is_expired_token(&error) => {
self.refresh_token()
.await
.map_err(|error| self.failing(error))?;
self.watch_once(None)
.await
.map_err(|error| self.failing(error))?
}
outcome => outcome.map_err(|error| self.failing(error))?,
};
const MOST_TOKEN_RECOVERIES: u32 = 3;
let mut token_recoveries = 0_u32;
let mut resume_from: Option<i64> = None;
loop {
let response = match stream.message().await {
Ok(Some(response)) => {
token_recoveries = 0;
if let Some(header) = response.header() {
resume_from = Some(header.revision() + 1);
}
response
}
Ok(None) => break,
Err(error) => {
let wrapped = classified(
&format!("{}: the watch failed: {error}", self.describe()),
&error,
);
if is_expired_token(&wrapped) {
token_recoveries += 1;
if token_recoveries > MOST_TOKEN_RECOVERIES {
return Err(self.failing(wrapped));
}
self.refresh_token()
.await
.map_err(|error| self.failing(error))?;
stream = self
.watch_once(resume_from)
.await
.map_err(|error| self.failing(error))?;
continue;
}
return Err(self.failing(wrapped));
}
};
if response.canceled() {
return Err(self.failing(Error::remote(format!(
"{}: the store cancelled the watch: {}",
self.describe(),
response.cancel_reason()
))));
}
if let Keys::Prefix(prefix) = &self.keys {
if response.events().is_empty() {
continue;
}
let Some(revision) = response.header().map(etcd_client::ResponseHeader::revision)
else {
continue;
};
let documents = match self.range_at(prefix, revision).await {
Err(error) if is_expired_token(&error) => {
self.refresh_token()
.await
.map_err(|error| self.failing(error))?;
self.range_at(prefix, revision)
.await
.map_err(|error| self.failing(error))?
}
outcome => outcome.map_err(|error| self.failing(error))?,
};
if documents.is_empty() {
continue;
}
let document =
documents::merged(&documents, format, Overlap::Refused, &self.describe())
.map_err(|error| self.failing(error))?;
guarded(&mut on_change, document, &self.describe())?;
continue;
}
for event in response.events() {
if event.event_type() != EventType::Put {
continue;
}
let Some(value) = event.kv() else { continue };
let text = value.value_str().map_err(|error| {
self.failing(Error::remote(format!(
"{}: the value is not UTF-8: {error}",
self.describe()
)))
})?;
guarded(&mut on_change, Fetched::new(text, format), &self.describe())?;
}
}
Err(self.failing(Error::remote(format!(
"{}: the watch ended; the connection was closed",
self.describe()
))))
}
async fn refresh_token(&self) -> Result<(), Error> {
self.client
.lock()
.await
.refresh_token()
.await
.map_err(|error| {
classified(
&format!(
"{}: the auth token expired and could not be replaced: {error}",
self.describe()
),
&error,
)
})
}
}
impl Etcd {
async fn watch_once(
&self,
from_revision: Option<i64>,
) -> Result<etcd_client::WatchStream, Error> {
let mut options = etcd_client::WatchOptions::new();
if let Some(revision) = from_revision {
options = options.with_start_revision(revision);
}
let key = match &self.keys {
Keys::One(key) => key.as_str(),
Keys::Prefix(prefix) => {
options = options.with_prefix();
prefix.as_str()
}
Keys::Several(_) => {
return Err(Error::remote(format!(
"{}: only a single key or a prefix can be watched",
self.describe()
)))
}
};
self.client
.lock()
.await
.watch(key, Some(options))
.await
.map_err(|error| {
classified(
&format!("{}: cannot watch: {error}", self.describe()),
&error,
)
})
}
async fn range_at(&self, prefix: &str, revision: i64) -> Result<Vec<(String, String)>, Error> {
let read = async {
let response = self
.client
.lock()
.await
.get(prefix, Some(prefix_options(Some(revision))))
.await
.map_err(|error| self.wrapped(&error))?;
documents::within_key_budget(response.kvs().len(), &self.describe())?;
self.pairs_of(&response, None)
};
tokio::time::timeout(self.timeout, read)
.await
.unwrap_or_else(|_| {
Err(Error::remote(format!(
"{}: timed out after {:?} re-reading the range the watch \
reported a change to",
self.describe(),
self.timeout
)))
})
}
async fn get_once(&self) -> Result<Vec<(String, String)>, Error> {
if let Keys::Several(keys) = &self.keys {
if keys.len() > MOST_TRANSACTION_KEYS {
return Err(Error::remote(format!(
"{}: {} keys is more than the {MOST_TRANSACTION_KEYS} one etcd \
transaction carries (`--max-txn-ops`); reading them would take \
several round trips at several revisions, which is the torn \
document this avoids — read a prefix, or install a source per \
group",
self.describe(),
keys.len()
)));
}
}
let read = async {
let mut client = self.client.lock().await;
match &self.keys {
Keys::One(key) => {
let response = client
.get(key.as_str(), None)
.await
.map_err(|error| self.wrapped(&error))?;
self.pairs_of(&response, Some(key))
}
Keys::Several(keys) => {
let transaction = etcd_client::Txn::new().and_then(
keys.iter()
.map(|key| etcd_client::TxnOp::get(key.as_str(), None))
.collect::<Vec<_>>(),
);
let answered = client
.txn(transaction)
.await
.map_err(|error| self.wrapped(&error))?;
let mut documents = Vec::with_capacity(keys.len());
for (key, answer) in keys.iter().zip(answered.op_responses()) {
let etcd_client::TxnOpResponse::Get(response) = answer else {
return Err(Error::remote(format!(
"{}: the store answered a read with something else",
self.describe()
)));
};
documents.extend(self.pairs_of(&response, Some(key))?);
}
Ok(documents)
}
Keys::Prefix(prefix) => {
let response = client
.get(prefix.as_str(), Some(prefix_options(None)))
.await
.map_err(|error| self.wrapped(&error))?;
documents::within_key_budget(response.kvs().len(), &self.describe())?;
self.pairs_of(&response, None)
}
}
};
tokio::time::timeout(self.timeout, read)
.await
.unwrap_or_else(|_| {
Err(Error::remote(format!(
"{}: timed out after {:?}",
self.describe(),
self.timeout
)))
})
}
fn pairs_of(
&self,
response: &etcd_client::GetResponse,
expected: Option<&str>,
) -> Result<Vec<(String, String)>, Error> {
if let Some(key) = expected {
if response.kvs().is_empty() {
return Err(Error::remote(format!(
"{}: `{key}` holds no value",
self.describe()
)));
}
}
response
.kvs()
.iter()
.map(|value| {
let key = value.key_str().map_err(|error| {
Error::remote(format!("{}: a key is not UTF-8: {error}", self.describe()))
})?;
if let Keys::Prefix(prefix) = &self.keys {
documents::under_prefix(key, prefix, &self.describe())?;
}
let text = value.value_str().map_err(|error| {
Error::remote(format!(
"{}: `{key}` is not UTF-8: {error}",
self.describe()
))
})?;
Ok((key.to_owned(), text.to_owned()))
})
.collect()
}
fn wrapped(&self, error: &etcd_client::Error) -> Error {
classified(&format!("{}: {error}", self.describe()), error)
}
}
fn prefix_options(revision: Option<i64>) -> etcd_client::GetOptions {
let options = etcd_client::GetOptions::new()
.with_prefix()
.with_limit(i64::try_from(documents::MOST_KEYS.saturating_add(1)).unwrap_or(i64::MAX));
match revision {
Some(revision) => options.with_revision(revision),
None => options,
}
}
fn classified(message: &str, error: &etcd_client::Error) -> Error {
match error {
etcd_client::Error::GRpcStatus(status) if is_auth_refusal(status.message()) => {
Error::auth(message)
}
_ => Error::remote(message),
}
}
fn is_auth_refusal(message: &str) -> bool {
AUTH_REFUSALS
.iter()
.any(|refusal| message.contains(refusal))
}
fn is_expired_token(error: &Error) -> bool {
error.to_string().contains(INVALID_TOKEN)
}
async fn connect(
endpoints: &[String],
options: &ConnectOptions,
described: &str,
) -> Result<Client, Error> {
Client::connect(endpoints, Some(options.clone()))
.await
.map_err(|error| classified(&format!("etcd {described}: {error}"), &error))
}
impl AsyncRemoteSource for Etcd {
fn fetch(&self) -> Pin<Box<dyn Future<Output = Result<Fetched, Error>> + Send + '_>> {
Box::pin(async move {
let format = self.format()?;
let documents = match self.get_once().await {
Err(error) if is_expired_token(&error) => {
self.refresh_token().await?;
self.get_once().await?
}
outcome => outcome?,
};
documents::merged(&documents, format, self.overlap(), &self.describe())
})
}
fn describe(&self) -> String {
format!("etcd {} {}", self.endpoints, self.keys.describe())
}
}
impl Etcd {
fn overlap(&self) -> Overlap {
match self.keys {
Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
Keys::Prefix(_) => Overlap::Refused,
}
}
}
#[cfg(feature = "tls")]
fn tls_options(tls: &TlsConfig, described: &str) -> Result<TlsOptions, Error> {
let mut options = TlsOptions::new();
if let Some(pem) = tls.ca_certificate_pem(described)? {
options = options.ca_certificate(Certificate::from_pem(pem));
}
if let Some((certificate, key)) = tls.client_certificate_pem(described)? {
options = options.identity(Identity::from_pem(certificate, key));
}
Ok(options)
}
impl std::fmt::Debug for Etcd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Etcd")
.field("endpoints", &self.endpoints)
.field("keys", &self.keys)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn etcds_own_refusals_are_recognised() {
for message in [
"etcdserver: invalid auth token",
"etcdserver: authentication failed, invalid user ID or password",
"etcdserver: permission denied",
"etcdserver: user name is empty",
"etcdserver: user name not found",
] {
assert!(is_auth_refusal(message), "{message}");
}
}
#[test]
fn an_ordinary_failure_is_not_an_auth_refusal() {
for message in [
"etcdserver: request timed out",
"etcdserver: too many requests",
"transport error: connection refused",
"Permission denied (os error 13)",
] {
assert!(!is_auth_refusal(message), "{message}");
}
}
#[tokio::test]
async fn a_fetch_from_a_server_that_never_answers_ends_at_the_deadline() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let silent = std::thread::spawn(move || {
let held = listener.accept();
std::thread::sleep(Duration::from_secs(2));
drop(held);
});
let source = Etcd::new([address], "myapp/db.json")
.await
.expect("the endpoint parses; connecting is lazy")
.with_timeout(Duration::from_millis(200));
let started = std::time::Instant::now();
let error = source.fetch().await.expect_err("nothing ever answers");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"the deadline must bound the fetch, not merely the connect: {elapsed:?}"
);
assert!(error.to_string().contains("timed out"), "{error}");
assert_eq!(
error.kind(),
dynamic_config::ErrorKind::Remote,
"a store that went quiet may yet come back; that is not an auth failure"
);
let _ = silent.join();
}
#[tokio::test]
async fn neither_an_error_nor_debug_prints_a_credential() {
let error = Etcd::with_options(
["http://127.0.0.1:9"],
"myapp/db.json",
ConnectOptions::new().with_user("myapp", "hunter2-etcd-password"),
)
.await
.expect_err("nothing is listening");
let printed = format!("{error} {error:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("127.0.0.1:9"), "{printed}");
assert_eq!(
error.kind(),
dynamic_config::ErrorKind::Remote,
"a refused connection is the store being unreachable, not the \
credentials being wrong — a watch loop backs off on one and \
stops on the other"
);
}
#[tokio::test]
async fn debug_names_the_store_and_nothing_else() {
let source = Etcd::new(["http://127.0.0.1:9"], "myapp/db.json")
.await
.expect("the endpoint parses; connecting is lazy");
let printed = format!("{source:?}");
assert!(printed.contains("myapp/db.json"), "{printed}");
assert!(printed.contains("127.0.0.1:9"), "{printed}");
}
#[test]
fn a_bare_key_is_still_one_key() {
assert_eq!(Keys::from("myapp/db.json"), Keys::one("myapp/db.json"));
assert_eq!(
Keys::from("myapp/db.json".to_owned()),
Keys::one("myapp/db.json")
);
}
#[tokio::test]
async fn describe_names_every_key_in_the_set() {
let several = Etcd::new(
["http://127.0.0.1:9"],
Keys::several(["myapp/base.json", "myapp/local.json"]),
)
.await
.expect("the endpoint parses");
assert!(
several.describe().contains("myapp/base.json")
&& several.describe().contains("myapp/local.json"),
"{}",
several.describe()
);
let prefix = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
.await
.expect("the endpoint parses");
assert!(
prefix.describe().contains("prefix myapp/"),
"{}",
prefix.describe()
);
}
#[tokio::test]
async fn a_prefix_with_no_format_says_which_call_supplies_one() {
let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
.await
.expect("the endpoint parses");
let error = source.fetch().await.expect_err("no format was ever named");
assert!(error.to_string().contains("with_format"), "{error}");
}
#[tokio::test]
async fn keys_naming_two_formats_are_refused_until_one_is_chosen() {
let source = Etcd::new(
["http://127.0.0.1:9"],
Keys::several(["myapp/db.json", "myapp/server.toml"]),
)
.await
.expect("the endpoint parses");
let error = source
.fetch()
.await
.expect_err("json and toml cannot both be the format");
assert!(error.to_string().contains("myapp/db.json"), "{error}");
assert!(error.to_string().contains("myapp/server.toml"), "{error}");
let settled = Etcd::new(
["http://127.0.0.1:9"],
Keys::several(["myapp/db.json", "myapp/server.toml"]),
)
.await
.expect("the endpoint parses")
.with_format(Format::Json);
let error = settled.fetch().await.expect_err("nothing is listening");
assert!(!error.to_string().contains("with_format"), "{error}");
}
#[tokio::test]
async fn a_named_list_longer_than_one_transaction_is_refused() {
let keys: Vec<String> = (0..=MOST_TRANSACTION_KEYS)
.map(|n| format!("myapp/{n:04}.json"))
.collect();
let source = Etcd::new(["http://127.0.0.1:9"], Keys::several(keys))
.await
.expect("the endpoint parses");
let error = source.fetch().await.expect_err("one key too many");
assert!(error.to_string().contains("max-txn-ops"), "{error}");
assert!(
error.to_string().contains("129"),
"the count belongs in the message: {error}"
);
}
#[tokio::test]
async fn a_named_list_refuses_to_be_watched_and_says_what_to_do() {
let source = Etcd::new(
["http://127.0.0.1:9"],
Keys::several(["myapp/db.json", "myapp/server.json"]),
)
.await
.expect("the endpoint parses");
let error = source
.watch(|_| Ok(()))
.await
.expect_err("a named list cannot be watched");
assert!(error.to_string().contains("cannot be watched"), "{error}");
assert!(
error.to_string().contains("refresh_remote_async"),
"{error}"
);
assert!(
error.to_string().contains("watch a prefix"),
"the refusal must name the shape that does work: {error}"
);
}
#[tokio::test]
async fn a_prefix_is_not_refused_at_the_door() {
let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
.await
.expect("the endpoint parses")
.with_format(Format::Json);
let error = source
.watch(|_| Ok(()))
.await
.expect_err("nothing is listening on port 9");
assert!(
!error.to_string().contains("cannot be watched"),
"a prefix watch must fail on the connection, not on a refusal: {error}"
);
}
#[tokio::test]
async fn a_watch_that_cannot_be_established_reports_the_store_as_unreachable() {
use dynamic_config::{Remote, RemoteSink};
static UNREACHABLE: Remote = Remote::new();
fn reloaded() -> Result<(), Error> {
Ok(())
}
let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
.await
.expect("the endpoint parses; connecting is lazy")
.with_format(Format::Json)
.reporting_to(RemoteSink::new(&UNREACHABLE, reloaded, "etcd"));
source
.watch(|_| Ok(()))
.await
.expect_err("nothing is listening on port 9");
let status = UNREACHABLE.status();
assert_eq!(
status.consecutive_failures, 1,
"one attempt, reported exactly once — a site reported twice would \
make the streak a count of branches rather than of attempts"
);
assert_eq!(status.reachable(), Some(false));
assert_eq!(
status.fetches, 0,
"an attempt that returned nothing is not a fetch"
);
assert_eq!(
status.last_fetch, None,
"and it must not invent a read that never happened"
);
let failure = status.last_failure.as_ref().expect("the attempt failed");
assert_eq!(failure.kind, dynamic_config::ErrorKind::Remote);
assert!(
!format!("{status:?}").contains("127.0.0.1"),
"a store's address never enters a status: {status:?}"
);
}
#[tokio::test]
async fn a_refusal_before_the_first_round_trip_is_not_a_store_that_stopped_answering() {
use dynamic_config::{Remote, RemoteSink};
static REFUSED: Remote = Remote::new();
fn reloaded() -> Result<(), Error> {
Ok(())
}
let source = Etcd::new(
["http://127.0.0.1:9"],
Keys::several(["myapp/db.json", "myapp/server.json"]),
)
.await
.expect("the endpoint parses")
.reporting_to(RemoteSink::new(&REFUSED, reloaded, "etcd"));
let error = source
.watch(|_| Ok(()))
.await
.expect_err("a named list cannot be watched");
assert!(error.to_string().contains("cannot be watched"), "{error}");
assert_eq!(
REFUSED.status().reachable(),
None,
"nothing has been asked of this cluster, so it is neither up nor down"
);
}
#[tokio::test]
async fn a_source_that_reports_nowhere_fails_exactly_as_it_always_did() {
let source = Etcd::new(["http://127.0.0.1:9"], Keys::prefix("myapp/"))
.await
.expect("the endpoint parses")
.with_format(Format::Json);
let error = source
.watch(|_| Ok(()))
.await
.expect_err("nothing is listening on port 9");
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote);
assert!(error.to_string().contains("cannot watch"), "{error}");
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_missing_ca_file_names_the_path_and_the_material() {
let error = Etcd::with_tls(
["https://127.0.0.1:9"],
"myapp/db.json",
ConnectOptions::new(),
&TlsConfig::new().with_ca_certificate_file("/nonexistent/etcd-ca.pem"),
)
.await
.expect_err("the CA file is not there");
assert!(
error.to_string().contains("/nonexistent/etcd-ca.pem"),
"{error}"
);
assert!(error.to_string().contains("the CA certificate"), "{error}");
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_private_key_never_reaches_an_error_or_a_debug() {
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
let tls = TlsConfig::new()
.with_ca_certificate_pem("-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n")
.with_client_certificate_pem(
"-----BEGIN CERTIFICATE-----\ncert\n-----END CERTIFICATE-----\n",
format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
);
assert!(!format!("{tls:?}").contains(PLANTED), "{tls:?}");
let error = Etcd::with_tls(
["https://127.0.0.1:9"],
"myapp/db.json",
ConnectOptions::new(),
&tls,
)
.await
.expect_err("that is not a certificate");
assert!(!error.to_string().contains(PLANTED), "{error}");
assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_client_certificate_with_no_readable_key_is_refused() {
let error = Etcd::with_tls(
["https://127.0.0.1:9"],
"myapp/db.json",
ConnectOptions::new(),
&TlsConfig::new().with_client_certificate_files(
"/nonexistent/client.crt",
"/nonexistent/client.key",
),
)
.await
.expect_err("neither file is there");
assert!(
error.to_string().contains("the client certificate"),
"{error}"
);
}
}