Skip to main content

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}