dynamic_config_store_core/lib.rs
1//! Machinery the [`dynamic-config`] store crates share.
2//!
3//! **This crate has no stable API.** It is published only because the seven
4//! store crates are published and cargo will not let a published crate depend
5//! on one that is not. Depend on [`dynamic-config-consul`], [`-etcd`],
6//! [`-firestore`], [`-nats`], [`-redis`], [`-s3`] or [`-vault`]; nothing here
7//! is meant to be named directly, and anything here may change in a patch
8//! release.
9//!
10//! What lives here is what was *identical* in more than one store crate and
11//! carried a decision worth making once:
12//!
13//! - [`attempts`] — where a watch loop reports an attempt that came back
14//! with nothing, so a store that has stopped answering stops looking
15//! healthy. Seven loops, one line each.
16//! - [`credential`] — when to obtain, reuse and refresh a token that expires.
17//! Consul, Vault and Firestore each kept a copy; the differences between
18//! them stayed in the stores, and that module's documentation says which
19//! and why.
20//! - [`documents`] — folding several keys into the one document `fetch`
21//! returns: the ordering rule, the collision report, and the limits an
22//! untrusted key list is held to. Three stores read several keys and all
23//! three would otherwise have written the same merge.
24//! - [`guarded`] — running a watch callback with a panic net. Seven copies,
25//! byte for byte.
26//! - [`redacted`] and [`redacted_list`] — removing a credential from a store
27//! URL before it reaches an error message. Two copies, differing in one
28//! documented way that is now a parameter rather than a fork.
29//! - [`tls`] — a custom certificate authority and a client certificate, as
30//! data rather than as whichever type the store's client happens to use.
31//! Not a deduplication of seven copies: there were none, and four stores
32//! had no way to say it at all.
33//!
34//! What is deliberately *not* here: each store's retry policy, its timeout
35//! default, and the vocabulary it sorts its own failures with. Those look
36//! alike from a distance and are different decisions up close — a Vault 403
37//! and a Firestore 401 mean the same thing to a person and nothing to a
38//! `match`.
39//!
40//! [`dynamic-config`]: https://docs.rs/dynamic-config
41//! [`dynamic-config-consul`]: https://docs.rs/dynamic-config-consul
42//! [`-etcd`]: https://docs.rs/dynamic-config-etcd
43//! [`-firestore`]: https://docs.rs/dynamic-config-firestore
44//! [`-nats`]: https://docs.rs/dynamic-config-nats
45//! [`-redis`]: https://docs.rs/dynamic-config-redis
46//! [`-s3`]: https://docs.rs/dynamic-config-s3
47//! [`-vault`]: https://docs.rs/dynamic-config-vault
48
49#![forbid(unsafe_code)]
50#![deny(missing_docs)]
51
52pub mod attempts;
53pub mod credential;
54pub mod documents;
55pub mod tls;
56
57use dynamic_config::{Error, Fetched};
58
59/// Runs the watch callback with a panic net.
60///
61/// The callback is the caller's code on the caller's thread; a panic in it
62/// used to unwind through the watch loop and kill that thread with the
63/// `RemoteWatch` handle still looking alive. Caught, it becomes an orderly
64/// error: the watch ends, and the caller is told why.
65///
66/// # Errors
67///
68/// Whatever the callback returns, or a `Remote` error naming the store if it
69/// panicked.
70pub fn guarded<F>(on_change: &mut F, document: Fetched, described: &str) -> Result<(), Error>
71where
72 F: FnMut(Fetched) -> Result<(), Error>,
73{
74 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| on_change(document))).unwrap_or_else(
75 |_| {
76 Err(Error::remote(format!(
77 "{described}: the watch callback panicked; the watch is stopped"
78 )))
79 },
80 )
81}
82
83/// What a URL authority with no colon in it means.
84///
85/// `scheme://something@host` is a shape more than one store accepts, and the
86/// two stores that accept it disagree about what `something` is. Naming the
87/// disagreement is the point: it is one line of difference between NATS and
88/// Redis, and it used to be two copies of the whole function.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum LoneAuthority {
91 /// The whole thing is the credential — NATS' `nats://token@host`.
92 Secret,
93 /// The whole thing is a user name, and the password is elsewhere or
94 /// absent — Redis' `redis://user@host`.
95 Username,
96}
97
98/// A URL with its credentials removed, for error messages.
99///
100/// `redis://user:hunter2@host` in a log is a credential in a log, and a store
101/// URL is quoted into every error message and into `Debug`.
102///
103/// A URL this cannot parse is returned unchanged rather than blanked: the
104/// shapes below are the ones that carry a credential, and something that is
105/// not one of them is a string the caller needs to see to fix their
106/// configuration.
107#[must_use]
108pub fn redacted(url: &str, lone: LoneAuthority) -> String {
109 let Some((scheme, rest)) = url.split_once("://") else {
110 return url.to_owned();
111 };
112
113 // `rsplit_once`, not `split_once`: a password may itself contain `@`
114 // (`redis://user:p@ss@host`), and splitting on the *first* one would keep
115 // the tail of the password in the "redacted" output.
116 let Some((authority, tail)) = rest.rsplit_once('@') else {
117 return url.to_owned();
118 };
119
120 match authority.split_once(':') {
121 // A user:password pair keeps the user, which is the half worth seeing.
122 Some((user, _)) => format!("{scheme}://{user}:***@{tail}"),
123 None => match lone {
124 LoneAuthority::Secret => format!("{scheme}://***@{tail}"),
125 LoneAuthority::Username => format!("{scheme}://{authority}:***@{tail}"),
126 },
127 }
128}
129
130/// A comma-separated list of URLs, each redacted by [`redacted`].
131///
132/// NATS accepts a list of servers in one string, so redacting the string as a
133/// whole would leave every server but the last one intact.
134#[must_use]
135pub fn redacted_list(urls: &str, lone: LoneAuthority) -> String {
136 urls.split(',')
137 .map(|url| redacted(url, lone))
138 .collect::<Vec<_>>()
139 .join(",")
140}
141
142#[cfg(test)]
143mod tests {
144 use dynamic_config::Format;
145
146 use super::*;
147
148 #[test]
149 fn a_panicking_callback_ends_the_watch_rather_than_the_thread() {
150 let mut on_change = |_: Fetched| -> Result<(), Error> { panic!("the caller's bug") };
151
152 let error = guarded(
153 &mut on_change,
154 Fetched::new("{}".to_owned(), Format::Json),
155 "store the-key",
156 )
157 .expect_err("a panic becomes an error");
158
159 assert!(error.to_string().contains("store the-key"), "{error}");
160 assert!(error.to_string().contains("panicked"), "{error}");
161 }
162
163 #[test]
164 fn a_callbacks_own_error_is_passed_through_unchanged() {
165 let mut on_change =
166 |_: Fetched| -> Result<(), Error> { Err(Error::remote("bad document")) };
167
168 let error = guarded(
169 &mut on_change,
170 Fetched::new("{}".to_owned(), Format::Json),
171 "store the-key",
172 )
173 .expect_err("the callback failed");
174
175 assert_eq!(error.to_string(), "bad document");
176 }
177
178 #[test]
179 fn a_password_never_reaches_an_error_message() {
180 for lone in [LoneAuthority::Secret, LoneAuthority::Username] {
181 assert_eq!(
182 redacted("redis://app:hunter2@redis.internal:6379", lone),
183 "redis://app:***@redis.internal:6379"
184 );
185 // A password may contain `@`; splitting on the first one would
186 // leave its tail in the "redacted" output.
187 assert_eq!(
188 redacted("redis://app:p@ss@w@rd@redis.internal:6379", lone),
189 "redis://app:***@redis.internal:6379"
190 );
191 assert_eq!(
192 redacted("redis://redis.internal:6379", lone),
193 "redis://redis.internal:6379"
194 );
195 assert_eq!(redacted("not a url", lone), "not a url");
196 }
197 }
198
199 #[test]
200 fn a_lone_authority_is_read_the_way_its_store_reads_it() {
201 // NATS' `nats://token@host`: the whole authority is the secret.
202 assert_eq!(
203 redacted(
204 "nats://hunter2-token@nats.internal:4222",
205 LoneAuthority::Secret
206 ),
207 "nats://***@nats.internal:4222"
208 );
209 // Redis' `redis://user@host`: the whole authority is a user name, and
210 // blanking it would hide the half worth seeing.
211 assert_eq!(
212 redacted("redis://app@redis.internal:6379", LoneAuthority::Username),
213 "redis://app:***@redis.internal:6379"
214 );
215 }
216
217 #[test]
218 fn every_server_in_a_list_is_redacted() {
219 assert_eq!(
220 redacted_list(
221 "nats://hunter2@a:4222,nats://hunter2@b:4222",
222 LoneAuthority::Secret
223 ),
224 "nats://***@a:4222,nats://***@b:4222"
225 );
226 }
227}