#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::sync::Mutex;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
use dynamic_config_store_core::attempts::Attempts;
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::{guarded, LoneAuthority};
use redis::Commands;
pub use redis::Client;
pub use dynamic_config_store_core::tls::TlsConfig;
const POLL_SLICE: Duration = Duration::from_millis(250);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const SCAN_BATCH: usize = 100;
const MOST_SCAN_ROUNDS: usize = 1_000;
#[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 Redis {
client: Client,
connection: Mutex<Option<redis::Connection>>,
keys: Keys,
format: Option<Format>,
disagreement: Option<String>,
described: String,
timeout: Duration,
attempts: Attempts,
}
impl Redis {
pub fn new(url: &str, keys: impl Into<Keys>) -> Result<Self, Error> {
let client = Client::open(url)
.map_err(|error| Error::remote(format!("redis {}: {error}", redacted(url))))?;
Ok(Self::build(client, keys, redacted(url)))
}
#[cfg(feature = "tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "tls")))]
pub fn with_tls(url: &str, keys: impl Into<Keys>, tls: &TlsConfig) -> Result<Self, Error> {
use redis::IntoConnectionInfo;
let described = format!("redis {}", redacted(url));
let info = url
.into_connection_info()
.map_err(|error| Error::remote(format!("{described}: {error}")))?;
if !matches!(info.addr(), redis::ConnectionAddr::TcpTls { .. }) {
return Err(Error::remote(format!(
"{described}: TLS material was supplied for a URL that is not \
`rediss://`; the material is refused rather than ignored"
)));
}
let certificates = redis::TlsCertificates {
root_cert: tls.ca_certificate_pem(&described)?,
client_tls: tls
.client_certificate_pem(&described)?
.map(|(client_cert, client_key)| redis::ClientTlsConfig {
client_cert,
client_key,
}),
};
let client = Client::build_with_tls(info, certificates).map_err(|_| {
Error::remote(format!(
"{described}: the TLS material was refused; check that the CA \
certificate, the client certificate and the private key are \
PEM-encoded material of the kind expected"
))
})?;
Ok(Self::build(client, keys, redacted(url)))
}
#[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>, described: 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,
connection: Mutex::new(None),
keys,
format,
disagreement,
described,
timeout: DEFAULT_TIMEOUT,
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 with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
*self
.connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
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
}
pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
self.format()?;
let keys: Vec<String> = self.watched()?.to_vec();
self.require_keyspace_notifications()
.map_err(|error| self.failing(error))?;
let mut subscriber = self
.client
.get_connection_with_timeout(self.timeout)
.map_err(|error| {
self.failing(Error::remote(format!(
"{}: cannot subscribe: {error}",
self.describe()
)))
})?;
let database = self.database().ok_or_else(|| {
self.failing(Error::remote(format!(
"{}: cannot determine the database index the connection lands on, so the keyspace channel cannot be named",
self.describe()
)))
})?;
let mut pubsub = subscriber.as_pubsub();
for key in &keys {
let channel = format!("__keyspace@{database}__:{key}");
pubsub.subscribe(&channel).map_err(|error| {
self.failing(Error::remote(format!(
"{}: cannot subscribe: {error}",
self.describe()
)))
})?;
}
pubsub.set_read_timeout(Some(POLL_SLICE)).map_err(|error| {
self.failing(Error::remote(format!("{}: {error}", self.describe())))
})?;
let mut last: Option<String> = None;
let coalescing = keys.len() > 1;
while watching.keep_going() {
let message = match pubsub.get_message() {
Ok(message) => message,
Err(error) if error.is_timeout() => continue,
Err(error) => {
let error = Error::remote(format!(
"{}: the subscription failed: {error}",
self.describe()
));
self.attempts.failed(&error);
return Err(error);
}
};
let event: String = message.get_payload().unwrap_or_default();
if event == "del" || event == "expired" {
continue;
}
let document = match self.fetch() {
Ok(document) => document,
Err(error) => {
self.attempts.failed(&error);
continue;
}
};
if coalescing {
if last.as_deref() == Some(document.text.as_str()) {
continue;
}
last = Some(document.text.clone());
}
guarded(&mut on_change, document, &self.describe())?;
}
Ok(())
}
fn database(&self) -> Option<i64> {
let mut connection = self.client.get_connection_with_timeout(self.timeout).ok()?;
let info: String = redis::cmd("CLIENT")
.arg("INFO")
.query(&mut connection)
.ok()?;
info.split_whitespace()
.find_map(|field| field.strip_prefix("db="))
.and_then(|value| value.parse().ok())
}
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 watched(&self) -> Result<&[String], Error> {
if let Keys::Prefix(_) = &self.keys {
return Err(Error::remote(format!(
"{}: a source that reads a prefix cannot be watched; finding \
the keys again means a `SCAN`, which is a cursor over many \
commands rather than one operation, so the set could be \
collected half from before a write and half from after — \
name the keys with `Keys::several`, which is watched as one \
`MGET`, or poll `refresh_remote()` on a timer instead",
self.describe()
)));
}
match self.keys.named() {
[] => Err(Error::remote(format!(
"{}: there are no keys to watch",
self.describe()
))),
keys => Ok(keys),
}
}
fn overlap(&self) -> Overlap {
match self.keys {
Keys::One(_) | Keys::Several(_) => Overlap::LaterWins,
Keys::Prefix(_) => Overlap::Refused,
}
}
fn open(&self) -> Result<redis::Connection, Error> {
let connection = self
.client
.get_connection_with_timeout(self.timeout)
.map_err(|error| self.classified(&error))?;
let _ = connection.set_read_timeout(Some(self.timeout));
let _ = connection.set_write_timeout(Some(self.timeout));
Ok(connection)
}
fn classified(&self, error: &redis::RedisError) -> Error {
let described = format!("{}: {error}", self.describe());
if error.kind() == redis::ErrorKind::AuthenticationFailed
|| matches!(error.code(), Some("NOAUTH" | "WRONGPASS" | "NOPERM"))
{
return Error::auth(described);
}
Error::remote(described)
}
fn read(&self) -> Result<Vec<(String, String)>, Error> {
let mut slot = self
.connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let connection = match slot.as_mut() {
Some(connection) => connection,
None => slot.insert(self.open()?),
};
let keys = match &self.keys {
Keys::One(key) => vec![key.clone()],
Keys::Several(keys) => keys.clone(),
Keys::Prefix(prefix) => self.scan(connection, prefix)?,
};
if keys.is_empty() {
return Err(Error::remote(format!(
"{}: nothing matched, so there is nothing to load",
self.describe()
)));
}
let values: Vec<Option<String>> = connection.mget(&keys).map_err(|error| {
self.classified(&error)
})?;
keys.into_iter()
.zip(values)
.map(|(key, value)| {
let text = value.ok_or_else(|| {
Error::remote(format!("{}: `{key}` holds no value", self.describe()))
})?;
Ok((key, text))
})
.collect()
}
fn scan(&self, connection: &mut redis::Connection, prefix: &str) -> Result<Vec<String>, Error> {
let pattern = format!("{}*", globbed(prefix));
let mut cursor = 0_u64;
let mut found: Vec<String> = Vec::new();
for _ in 0..MOST_SCAN_ROUNDS {
let (next, batch): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(SCAN_BATCH)
.query(connection)
.map_err(|error| self.classified(&error))?;
for key in batch {
documents::under_prefix(&key, prefix, &self.describe())?;
found.push(key);
}
found.sort();
found.dedup();
documents::within_key_budget(found.len(), &self.describe())?;
cursor = next;
if cursor == 0 {
return Ok(found);
}
}
Err(Error::remote(format!(
"{}: the scan did not finish in {MOST_SCAN_ROUNDS} rounds; \
the server is not advancing the cursor",
self.describe()
)))
}
fn require_keyspace_notifications(&self) -> Result<(), Error> {
let mut connection = self.open()?;
let settings: Vec<String> = redis::cmd("CONFIG")
.arg("GET")
.arg("notify-keyspace-events")
.query(&mut connection)
.map_err(|error| self.classified(&error))?;
let value = settings.get(1).map(String::as_str).unwrap_or_default();
if value.contains('K') {
return Ok(());
}
Err(Error::remote(format!(
"{}: keyspace notifications are off, so nothing would ever arrive; \
`CONFIG SET notify-keyspace-events KEA` on the server",
self.describe()
)))
}
}
impl RemoteSource for Redis {
fn fetch(&self) -> Result<Fetched, Error> {
let format = self.format()?;
match self.read().and_then(|documents| {
documents::merged(&documents, format, self.overlap(), &self.describe())
}) {
Ok(document) => Ok(document),
Err(error) => {
*self
.connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
Err(error)
}
}
}
fn describe(&self) -> String {
format!("redis {} {}", self.described, self.keys.describe())
}
}
impl std::fmt::Debug for Redis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Redis")
.field("server", &self.described)
.field("keys", &self.keys)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
fn globbed(literal: &str) -> String {
let mut escaped = String::with_capacity(literal.len());
for character in literal.chars() {
if matches!(character, '\\' | '*' | '?' | '[' | ']') {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
fn redacted(url: &str) -> String {
dynamic_config_store_core::redacted(url, LoneAuthority::Username)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
#[test]
fn a_password_never_reaches_an_error_message() {
assert_eq!(
redacted("redis://app:hunter2@redis.internal:6379"),
"redis://app:***@redis.internal:6379"
);
}
#[test]
fn a_password_containing_at_signs_is_fully_redacted() {
assert_eq!(
redacted("redis://app:p@ss@w@rd@redis.internal:6379"),
"redis://app:***@redis.internal:6379"
);
}
#[test]
fn a_url_with_no_credentials_is_left_alone() {
assert_eq!(
redacted("redis://redis.internal:6379"),
"redis://redis.internal:6379"
);
assert_eq!(redacted("not a url"), "not a url");
}
#[test]
fn the_format_comes_from_the_keys_extension() {
let client = Client::open("redis://127.0.0.1:6379").unwrap();
let source = Redis::from_client(client, "myapp/db.json");
assert_eq!(source.format, Some(Format::Json));
}
#[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")
);
}
#[test]
fn a_prefix_is_matched_as_a_literal_and_not_as_a_glob() {
assert_eq!(globbed("myapp/"), "myapp/");
assert_eq!(globbed("my[a]pp/"), r"my\[a\]pp/");
assert_eq!(globbed("my*pp/"), r"my\*pp/");
assert_eq!(globbed("my?pp/"), r"my\?pp/");
assert_eq!(globbed(r"my\pp/"), r"my\\pp/");
}
#[test]
fn describe_names_every_key_in_the_set() {
let client = Client::open("redis://127.0.0.1:6379").unwrap();
let several = Redis::from_client(client.clone(), Keys::several(["a.json", "b.json"]));
assert!(
several.describe().contains("a.json") && several.describe().contains("b.json"),
"{}",
several.describe()
);
let prefix = Redis::from_client(client, Keys::prefix("myapp/"));
assert!(
prefix.describe().contains("prefix myapp/"),
"{}",
prefix.describe()
);
}
#[test]
fn a_format_that_cannot_be_inferred_is_refused_before_any_request() {
let client = Client::open("redis://127.0.0.1:9").unwrap();
let error = Redis::from_client(client.clone(), Keys::prefix("myapp/"))
.fetch()
.expect_err("a prefix names no format");
assert!(error.to_string().contains("with_format"), "{error}");
let error = Redis::from_client(client, Keys::several(["db.json", "server.toml"]))
.fetch()
.expect_err("json and toml cannot both be it");
assert!(error.to_string().contains("db.json"), "{error}");
assert!(error.to_string().contains("server.toml"), "{error}");
}
#[test]
fn a_prefix_refuses_to_be_watched_and_a_named_list_is_not_refused_with_it() {
let client = Client::open("redis://127.0.0.1:9").unwrap();
let source =
Redis::from_client(client.clone(), Keys::prefix("myapp/")).with_format(Format::Json);
let watch = dynamic_config::RemoteWatch::new();
let error = source
.watch(&watch.watching(), |_| Ok(()))
.expect_err("a prefix cannot be watched");
assert!(error.to_string().contains("cannot be watched"), "{error}");
assert!(error.to_string().contains("SCAN"), "{error}");
assert!(error.to_string().contains("Keys::several"), "{error}");
let source = Redis::from_client(client, Keys::several(["a.json", "b.json"]));
let error = source
.watch(&watch.watching(), |_| Ok(()))
.expect_err("nothing is listening");
assert!(
!error.to_string().contains("cannot be watched"),
"a named list is watchable: {error}"
);
}
#[test]
fn no_watch_error_path_prints_a_credential() {
const URL: &str = "redis://app:p@ss@w@rd@127.0.0.1:9";
let watch = dynamic_config::RemoteWatch::new();
let refusals = [
Redis::new(URL, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json),
Redis::new(URL, Keys::several(Vec::<String>::new()))
.unwrap()
.with_format(Format::Json),
Redis::new(URL, Keys::several(["myapp/db.json", "myapp/server.json"])).unwrap(),
];
for source in refusals {
let error = source
.watch(&watch.watching(), |_| Ok(()))
.expect_err("every one of these refuses");
let printed = format!("{error} {error:?} {source:?}");
assert!(!printed.contains("p@ss"), "{printed}");
assert!(!printed.contains("w@rd"), "{printed}");
assert!(printed.contains("app:***@"), "{printed}");
}
}
#[test]
fn a_watch_on_an_empty_named_list_is_refused_rather_than_parked_forever() {
let client = Client::open("redis://127.0.0.1:9").unwrap();
let source = Redis::from_client(client, Keys::several(Vec::<String>::new()))
.with_format(Format::Json);
let watch = dynamic_config::RemoteWatch::new();
let error = source
.watch(&watch.watching(), |_| Ok(()))
.expect_err("there is nothing to subscribe to");
assert!(error.to_string().contains("no keys to watch"), "{error}");
}
fn scripted(reply: &'static str) -> (String, std::thread::JoinHandle<()>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("redis://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut buffer = [0u8; 4096];
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
return;
}
let commands = String::from_utf8_lossy(&buffer[..read])
.lines()
.filter(|line| line.starts_with('*'))
.count();
for _ in 0..commands.max(1) {
if stream.write_all(reply.as_bytes()).is_err() {
return;
}
}
}
});
(url, server)
}
#[test]
fn a_refused_password_is_an_auth_failure() {
let (url, server) = scripted("-WRONGPASS invalid username-password pair\r\n");
let source = Redis::new(&url, "myapp/db.json").unwrap();
let error = source.fetch().expect_err("the server refused the password");
drop(source);
let _ = server.join();
assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth, "{error}");
}
#[test]
fn a_missing_password_is_an_auth_failure() {
let (url, server) = scripted("-NOAUTH Authentication required.\r\n");
let source = Redis::new(&url, "myapp/db.json").unwrap();
let error = source.fetch().expect_err("the server wants a password");
drop(source);
let _ = server.join();
assert_eq!(error.kind(), dynamic_config::ErrorKind::Auth, "{error}");
}
#[test]
fn an_unreachable_server_is_remote_rather_than_auth() {
let source = Redis::new("redis://app:hunter2@127.0.0.1:9", "myapp/db.json").unwrap();
let error = source.fetch().expect_err("nothing is listening");
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote);
assert!(
!error.to_string().contains("hunter2"),
"the password must not reach the message: {error}"
);
}
type Asked = Arc<std::sync::Mutex<Vec<Vec<String>>>>;
fn by_verb(
replies: Vec<(&'static str, Vec<String>)>,
) -> (String, Asked, std::thread::JoinHandle<()>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("redis://{}", listener.local_addr().unwrap());
let asked: Asked = Arc::default();
let seen = Arc::clone(&asked);
let server = std::thread::spawn(move || {
let mut queued: std::collections::HashMap<&str, std::collections::VecDeque<String>> =
replies
.into_iter()
.map(|(verb, answers)| (verb, answers.into_iter().collect()))
.collect();
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut buffer = [0u8; 8192];
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
return;
}
for command in parsed(&String::from_utf8_lossy(&buffer[..read])) {
let verb = command[0].to_ascii_uppercase();
seen.lock().unwrap().push(command.clone());
let reply = queued
.get_mut(verb.as_str())
.and_then(std::collections::VecDeque::pop_front)
.unwrap_or_else(|| "+OK\r\n".to_owned());
if stream.write_all(reply.as_bytes()).is_err() {
return;
}
}
}
});
(url, asked, server)
}
fn parsed(text: &str) -> Vec<Vec<String>> {
let lines: Vec<&str> = text.split("\r\n").collect();
let mut commands = Vec::new();
let mut at = 0;
while at < lines.len() {
let Some(count) = lines[at]
.strip_prefix('*')
.and_then(|n| n.parse::<usize>().ok())
else {
at += 1;
continue;
};
let arguments: Vec<String> = (0..count)
.filter_map(|n| lines.get(at + 2 + n * 2).map(|value| (*value).to_owned()))
.collect();
if !arguments.is_empty() {
commands.push(arguments);
}
at += 1 + count * 2;
}
commands
}
fn resp_array(values: &[Option<&str>]) -> String {
let mut encoded = format!("*{}\r\n", values.len());
for value in values {
match value {
Some(text) => encoded.push_str(&format!("${}\r\n{text}\r\n", text.len())),
None => encoded.push_str("$-1\r\n"),
}
}
encoded
}
fn resp_scan(cursor: &str, keys: &[&str]) -> String {
let keys: Vec<Option<&str>> = keys.iter().map(|key| Some(*key)).collect();
format!(
"*2\r\n${}\r\n{cursor}\r\n{}",
cursor.len(),
resp_array(&keys)
)
}
#[test]
fn a_named_list_is_one_mget_in_call_order() {
let (url, asked, server) = by_verb(vec![(
"MGET",
vec![resp_array(&[
Some(r#"{"db": {"host": "base", "port": 5432}}"#),
Some(r#"{"db": {"port": 6432}}"#),
])],
)]);
let source =
Redis::new(&url, Keys::several(["myapp/base.json", "myapp/local.json"])).unwrap();
let fetched = source.fetch().expect("both keys answered");
drop(source);
let _ = server.join();
let asked = asked.lock().unwrap();
let mget = asked
.iter()
.find(|command| command[0].eq_ignore_ascii_case("MGET"))
.expect("one MGET");
assert_eq!(
mget[1..],
["myapp/base.json".to_owned(), "myapp/local.json".to_owned()],
"the caller's order is the merge order"
);
assert!(
!asked
.iter()
.any(|command| command[0].eq_ignore_ascii_case("GET")),
"a named list must not become one GET per key: {asked:?}"
);
let merged = dynamic_config::Value::parse(&fetched.text, Format::Json).unwrap();
assert_eq!(
merged.get("db.host"),
Some(&dynamic_config::Value::String("base".to_owned())),
"a key the later document never mentions survives"
);
assert_eq!(
merged.get("db.port"),
Some(&dynamic_config::Value::Integer(6432)),
"and the later document wins where they meet"
);
}
#[test]
fn a_prefix_scans_in_rounds_and_never_asks_for_keys() {
let (url, asked, server) = by_verb(vec![
(
"SCAN",
vec![
resp_scan("17", &["myapp/db.json"]),
resp_scan("0", &["myapp/server.json", "myapp/db.json"]),
],
),
(
"MGET",
vec![resp_array(&[
Some(r#"{"db": {"host": "db.internal"}}"#),
Some(r#"{"server": {"port": 8080}}"#),
])],
),
]);
let source = Redis::new(&url, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json);
let fetched = source.fetch().expect("the scan finished");
drop(source);
let _ = server.join();
let asked = asked.lock().unwrap();
let scans: Vec<&Vec<String>> = asked
.iter()
.filter(|command| command[0].eq_ignore_ascii_case("SCAN"))
.collect();
assert_eq!(scans.len(), 2, "the cursor is followed: {asked:?}");
assert_eq!(scans[0][1], "0", "the first round starts at zero");
assert_eq!(scans[1][1], "17", "and the next carries the cursor back");
assert!(
scans[0].iter().any(|argument| argument == "myapp/*"),
"the prefix goes out as a literal with one trailing star: {scans:?}"
);
assert!(
!asked
.iter()
.any(|command| command[0].eq_ignore_ascii_case("KEYS")),
"KEYS blocks a production server: {asked:?}"
);
let mget = asked
.iter()
.find(|command| command[0].eq_ignore_ascii_case("MGET"))
.expect("one MGET for the whole set");
assert_eq!(
mget[1..],
["myapp/db.json".to_owned(), "myapp/server.json".to_owned()],
"sorted and deduplicated, so the same keys give the same document"
);
let merged = dynamic_config::Value::parse(&fetched.text, Format::Json).unwrap();
assert_eq!(
merged.get("db.host"),
Some(&dynamic_config::Value::String("db.internal".to_owned()))
);
assert_eq!(
merged.get("server.port"),
Some(&dynamic_config::Value::Integer(8080))
);
}
#[test]
fn two_keys_under_a_prefix_supplying_one_path_are_refused_by_name() {
let (url, _asked, server) = by_verb(vec![
(
"SCAN",
vec![resp_scan("0", &["myapp/a.json", "myapp/b.json"])],
),
(
"MGET",
vec![resp_array(&[
Some(r#"{"db": {"password": "hunter2-first"}}"#),
Some(r#"{"db": {"password": "hunter2-second"}}"#),
])],
),
]);
let source = Redis::new(&url, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json);
let error = source.fetch().expect_err("both keys supply db.password");
drop(source);
let _ = server.join();
let printed = format!("{error} {error:?}");
assert!(printed.contains("myapp/a.json"), "{printed}");
assert!(printed.contains("myapp/b.json"), "{printed}");
assert!(printed.contains("db.password"), "{printed}");
assert!(
!printed.contains("hunter2"),
"a collision report names paths and never values: {printed}"
);
}
#[test]
fn one_key_holding_nothing_fails_the_whole_fetch_and_names_it() {
let (url, _asked, server) = by_verb(vec![(
"MGET",
vec![resp_array(&[Some(r#"{"db": {"host": "here"}}"#), None])],
)]);
let source =
Redis::new(&url, Keys::several(["myapp/db.json", "myapp/absent.json"])).unwrap();
let error = source.fetch().expect_err("the second key is not there");
drop(source);
let _ = server.join();
assert_eq!(error.kind(), dynamic_config::ErrorKind::Remote);
assert!(error.to_string().contains("myapp/absent.json"), "{error}");
assert!(error.to_string().contains("holds no value"), "{error}");
}
#[test]
fn a_key_outside_the_prefix_is_refused() {
let (url, _asked, server) = by_verb(vec![(
"SCAN",
vec![resp_scan("0", &["myapp/db.json", "other/db.json"])],
)]);
let source = Redis::new(&url, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json);
let error = source.fetch().expect_err("one key escaped the prefix");
drop(source);
let _ = server.join();
assert!(error.to_string().contains("other/db.json"), "{error}");
}
#[test]
fn a_cursor_that_never_advances_ends_the_scan_rather_than_the_process() {
let (url, _asked, server) = by_verb(vec![(
"SCAN",
std::iter::repeat_with(|| resp_scan("17", &[]))
.take(MOST_SCAN_ROUNDS + 8)
.collect(),
)]);
let source = Redis::new(&url, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json);
let error = source
.fetch()
.expect_err("the cursor never comes back to 0");
drop(source);
let _ = server.join();
assert!(
error.to_string().contains("advancing the cursor"),
"{error}"
);
}
#[test]
fn a_prefix_that_matches_nothing_is_a_failure_rather_than_an_empty_document() {
let (url, asked, server) = by_verb(vec![("SCAN", vec![resp_scan("0", &[])])]);
let source = Redis::new(&url, Keys::prefix("myapp/"))
.unwrap()
.with_format(Format::Json);
let error = source.fetch().expect_err("nothing matched");
drop(source);
let _ = server.join();
assert!(error.to_string().contains("nothing matched"), "{error}");
assert!(
!asked
.lock()
.unwrap()
.iter()
.any(|command| command[0].eq_ignore_ascii_case("MGET")),
"an MGET with no keys is a protocol error"
);
}
#[test]
fn no_multi_key_error_path_prints_a_credential() {
let source = Redis::new(
"redis://app:hunter2@127.0.0.1:9",
Keys::several(["myapp/db.json", "myapp/second.json"]),
)
.unwrap();
let error = source.fetch().expect_err("nothing is listening");
let printed = format!("{error} {error:?} {source:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("myapp/second.json"), "{printed}");
}
#[test]
fn a_read_from_a_server_that_never_answers_ends_at_the_deadline() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("redis://{}", 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 = Redis::new(&url, "myapp/db.json")
.unwrap()
.with_timeout(Duration::from_millis(200));
let started = std::time::Instant::now();
let error = source.fetch().expect_err("nothing ever answers");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"the deadline must bound the read, not merely the connect: {elapsed:?}"
);
assert_eq!(
error.kind(),
dynamic_config::ErrorKind::Remote,
"a store that went quiet may yet come back: {error}"
);
let _ = silent.join();
}
fn resp_bulk(text: &str) -> String {
format!("${}\r\n{text}\r\n", text.len())
}
fn subscribable(
channels: usize,
mget: Vec<String>,
then: Then,
) -> (String, Asked, Arc<std::sync::atomic::AtomicBool>) {
use std::sync::atomic::{AtomicBool, Ordering};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("redis://{}", listener.local_addr().unwrap());
let asked: Asked = Arc::default();
let stop = Arc::new(AtomicBool::new(false));
listener.set_nonblocking(true).unwrap();
let seen = Arc::clone(&asked);
let stopping = Arc::clone(&stop);
let queued: Arc<Mutex<std::collections::VecDeque<String>>> =
Arc::new(Mutex::new(mget.into_iter().collect()));
std::thread::spawn(move || {
while !stopping.load(Ordering::Acquire) {
match listener.accept() {
Ok((stream, _)) => {
stream.set_nonblocking(false).unwrap();
let seen = Arc::clone(&seen);
let queued = Arc::clone(&queued);
std::thread::spawn(move || serve(stream, &seen, &queued, channels, then));
}
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
}
});
(url, asked, stop)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Then {
Publishes,
HangsUp,
}
fn serve(
mut stream: std::net::TcpStream,
seen: &Asked,
queued: &Mutex<std::collections::VecDeque<String>>,
channels: usize,
then: Then,
) {
use std::io::{Read, Write};
let mut buffer = [0u8; 8192];
let mut subscribed: Vec<String> = Vec::new();
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
return;
}
for command in parsed(&String::from_utf8_lossy(&buffer[..read])) {
seen.lock().unwrap().push(command.clone());
let verb = command[0].to_ascii_uppercase();
let sub = command
.get(1)
.map(|argument| argument.to_ascii_uppercase())
.unwrap_or_default();
let reply = match (verb.as_str(), sub.as_str()) {
("CLIENT", "INFO") => {
resp_bulk("id=4 addr=127.0.0.1:1 laddr=127.0.0.1:2 fd=8 name= db=0 age=0")
}
("CONFIG", "GET") => resp_array(&[Some("notify-keyspace-events"), Some("KEA")]),
("SUBSCRIBE", _) => {
subscribed.push(command[1].clone());
format!(
"*3\r\n{}{}:{}\r\n",
resp_bulk("subscribe"),
resp_bulk(&command[1]),
subscribed.len()
)
}
("MGET", _) => queued
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| "*-1\r\n".to_owned()),
_ => "+OK\r\n".to_owned(),
};
if stream.write_all(reply.as_bytes()).is_err() {
return;
}
if verb == "SUBSCRIBE" && subscribed.len() == channels {
if then == Then::HangsUp {
return;
}
for channel in &subscribed {
let published = format!(
"*3\r\n{}{}{}",
resp_bulk("message"),
resp_bulk(channel),
resp_bulk("set")
);
if stream.write_all(published.as_bytes()).is_err() {
return;
}
}
}
}
}
}
#[test]
fn a_named_list_subscribes_to_every_key_and_re_reads_the_set_with_one_mget() {
let answer = resp_array(&[
Some(r#"{"db": {"host": "base", "port": 5432}}"#),
Some(r#"{"db": {"port": 6432}}"#),
]);
let (url, asked, stop) = subscribable(2, vec![answer.clone(), answer], Then::Publishes);
let source =
Redis::new(&url, Keys::several(["myapp/base.json", "myapp/local.json"])).unwrap();
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let (sender, receiver) = std::sync::mpsc::channel();
let loops = std::thread::spawn(move || {
source.watch(&watching, move |document| {
let _ = sender.send(document.text);
Ok(())
})
});
let text = receiver
.recv_timeout(Duration::from_secs(10))
.expect("the notification should reach the callback");
let merged = dynamic_config::Value::parse(&text, Format::Json).unwrap();
assert_eq!(
merged.get("db.host"),
Some(&dynamic_config::Value::String("base".to_owned())),
"the whole set is delivered, not the key that changed"
);
assert_eq!(
merged.get("db.port"),
Some(&dynamic_config::Value::Integer(6432)),
"and the caller's order is still the merge order"
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline {
let mgets = asked
.lock()
.unwrap()
.iter()
.filter(|command| command[0].eq_ignore_ascii_case("MGET"))
.count();
if mgets >= 2 {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
receiver.recv_timeout(Duration::from_millis(500)).is_err(),
"a set written together publishes once per key and is one document; \
delivering it per key would run every reload hook per key"
);
watch.stop();
stop.store(true, std::sync::atomic::Ordering::Release);
let outcome = loops.join().expect("the loop should end");
assert!(outcome.is_ok(), "{outcome:?}");
let asked = asked.lock().unwrap();
let subscribed: Vec<&String> = asked
.iter()
.filter(|command| command[0].eq_ignore_ascii_case("SUBSCRIBE"))
.map(|command| &command[1])
.collect();
assert_eq!(
subscribed,
[
"__keyspace@0__:myapp/base.json",
"__keyspace@0__:myapp/local.json"
],
"every key of the set is subscribed to, on the database the \
connection lands on: {asked:?}"
);
let mget = asked
.iter()
.find(|command| command[0].eq_ignore_ascii_case("MGET"))
.expect("the set is re-read with one MGET");
assert_eq!(
mget[1..],
["myapp/base.json".to_owned(), "myapp/local.json".to_owned()],
"one command carrying the whole set is what makes the delivery a \
state the server really had"
);
assert!(
!asked
.iter()
.any(|command| command[0].eq_ignore_ascii_case("GET")),
"a re-read key by key is the torn document this watch exists to \
avoid: {asked:?}"
);
assert!(
!asked
.iter()
.any(|command| command[0].eq_ignore_ascii_case("SCAN")),
"a named list knows its keys: {asked:?}"
);
}
#[test]
fn a_dead_subscription_reports_the_store_as_down_and_leaves_the_clock_running() {
use dynamic_config::dynamic_config;
#[dynamic_config]
#[derive(Debug, serde::Deserialize)]
struct Subscribed {
#[allow(dead_code)]
host: String,
}
let (url, _asked, stop) = subscribable(
1,
vec![resp_array(&[Some(r#"{"db": {"host": "base"}}"#)])],
Then::HangsUp,
);
Subscribed::set_remote(Redis::new(&url, "myapp/db.json").unwrap());
Subscribed::refresh_remote().expect("the store answers the first read");
let sink = Subscribed::remote_sink();
let before = sink.status();
assert_eq!(before.reachable(), Some(true), "one fetch, and it answered");
assert!(before.last_fetch.is_some());
let watcher = Redis::new(&url, "myapp/db.json")
.unwrap()
.reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let (ended, ending) = std::sync::mpsc::channel();
let loops = std::thread::spawn(move || {
let outcome = watcher.watch(&watching, |_| Ok(()));
let _ = ended.send(());
outcome
});
ending
.recv_timeout(Duration::from_secs(10))
.expect("a subscription that died ends the watch rather than spinning");
let outcome = loops.join().expect("the thread should end");
let error = outcome.expect_err("the subscription died");
watch.stop();
stop.store(true, std::sync::atomic::Ordering::Release);
assert!(
error.to_string().contains("the subscription failed"),
"{error}"
);
let after = sink.status();
assert_eq!(
after.reachable(),
Some(false),
"a loop that stopped reaching its store is a store that is down"
);
assert_eq!(after.consecutive_failures, 1);
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 subscription that dropped may yet come back"
);
assert!(!format!("{:?}", after.last_failure).contains("myapp/db.json"));
}
#[test]
fn a_failed_re_read_is_reported_and_the_watch_carries_on() {
use dynamic_config::dynamic_config;
#[dynamic_config]
#[derive(Debug, serde::Deserialize)]
struct Rereading {
#[allow(dead_code)]
host: String,
}
let (url, _asked, stop) = subscribable(
1,
vec![
resp_array(&[Some(r#"{"db": {"host": "base"}}"#)]),
resp_array(&[None]),
],
Then::Publishes,
);
Rereading::set_remote(Redis::new(&url, "myapp/db.json").unwrap());
Rereading::refresh_remote().expect("the store answers the first read");
let sink = Rereading::remote_sink();
let before = sink.status();
let watcher = Redis::new(&url, "myapp/db.json")
.unwrap()
.reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let watching = watch.watching();
let (ended, ending) = std::sync::mpsc::channel();
let loops = std::thread::spawn(move || {
let outcome = watcher.watch(&watching, |_| Ok(()));
let _ = ended.send(());
outcome
});
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while sink.status().consecutive_failures == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
let after = sink.status();
assert_eq!(
after.reachable(),
Some(false),
"the notification arrived and the document did not"
);
assert_eq!(
after.last_fetch, before.last_fetch,
"a failed re-read leaves the clock where the last document left it"
);
assert_eq!(after.fetches, before.fetches);
assert!(
ending.recv_timeout(Duration::from_millis(500)).is_err(),
"a failed re-read is transient: the next write notifies again, so \
the loop must still be running"
);
watch.stop();
stop.store(true, std::sync::atomic::Ordering::Release);
let outcome = loops.join().expect("the thread should end");
assert!(outcome.is_ok(), "stopping is not a failure: {outcome:?}");
}
#[test]
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 Refused {
#[allow(dead_code)]
host: String,
}
let sink = Refused::remote_sink();
let client = Client::open("redis://127.0.0.1:9").unwrap();
let source = Redis::from_client(client, Keys::prefix("myapp/"))
.with_format(Format::Json)
.reporting_to(sink);
let watch = dynamic_config::RemoteWatch::new();
let error = source
.watch(&watch.watching(), |_| Ok(()))
.expect_err("a prefix cannot be watched");
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"
);
}
#[cfg(feature = "tls")]
fn material() -> (String, String, String) {
use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
let ca_key = KeyPair::generate().unwrap();
let mut ca_params = CertificateParams::new(Vec::new()).unwrap();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
let ca = ca_params.self_signed(&ca_key).unwrap();
let issuer = rcgen::Issuer::from_params(&ca_params, &ca_key);
let client_key = KeyPair::generate().unwrap();
let client = CertificateParams::new(vec!["myapp".to_owned()])
.unwrap()
.signed_by(&client_key, &issuer)
.unwrap();
(ca.pem(), client.pem(), client_key.serialize_pem())
}
#[cfg(feature = "tls")]
#[test]
fn a_private_authority_and_a_client_certificate_build_a_client() {
let (ca, certificate, key) = material();
Redis::with_tls(
"rediss://127.0.0.1:6379",
"myapp/db.json",
&TlsConfig::new()
.with_ca_certificate_pem(ca)
.with_client_certificate_pem(certificate, key),
)
.expect("the generated material is valid PEM");
}
#[cfg(feature = "tls")]
#[test]
fn tls_material_on_a_plaintext_url_is_refused_rather_than_ignored() {
let (ca, _, _) = material();
let error = Redis::with_tls(
"redis://127.0.0.1:6379",
"myapp/db.json",
&TlsConfig::new().with_ca_certificate_pem(ca),
)
.expect_err("that URL negotiates no TLS at all");
assert!(error.to_string().contains("rediss://"), "{error}");
assert!(
error.to_string().contains("refused rather than ignored"),
"{error}"
);
}
#[cfg(feature = "tls")]
#[test]
fn a_malformed_private_key_never_quotes_itself_into_the_error() {
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
let (ca, certificate, _) = material();
let error = Redis::with_tls(
"rediss://127.0.0.1:6379",
"myapp/db.json",
&TlsConfig::new()
.with_ca_certificate_pem(ca)
.with_client_certificate_pem(
certificate,
format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
),
)
.expect_err("the key is not a key");
assert!(!error.to_string().contains(PLANTED), "{error}");
assert!(!format!("{error:?}").contains(PLANTED), "{error:?}");
}
#[cfg(feature = "tls")]
#[test]
fn a_tls_failure_never_carries_the_password_out_of_the_url() {
let error = Redis::with_tls(
"rediss://app:p@ss@w@rd@127.0.0.1:6379",
"myapp/db.json",
&TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem"),
)
.expect_err("the CA file is not there");
let printed = format!("{error} {error:?}");
assert!(!printed.contains("p@ss"), "{printed}");
assert!(printed.contains("app:***@"), "{printed}");
assert!(printed.contains("/nonexistent/private-ca.pem"), "{printed}");
}
}