dynamic_config_server/client/mod.rs
1//! Reading configuration *from* a config server.
2//!
3//! The other half of this crate, behind the `client` feature: a
4//! [`RemoteSource`] that fetches `GET /{application}/{profile}` and hands the
5//! document to the engine, exactly as an etcd or a Vault source does. The two
6//! halves live in one crate so that they are tested against each other —
7//! every test in `tests/client.rs` drives this against the real router rather
8//! than against a fixture of what the router is believed to return.
9//!
10//! ```no_run
11//! use std::time::Duration;
12//! use dynamic_config_server::client::ConfigServer;
13//!
14//! # fn main() -> Result<(), dynamic_config::Error> {
15//! let source = ConfigServer::new("https://config.internal", "billing", "prod")
16//! .with_token(std::env::var("CONFIG_TOKEN").unwrap_or_default())
17//! .with_timeout(Duration::from_secs(5));
18//! # let _ = source;
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! ## Watching
24//!
25//! It subscribes. `GET /{application}/{profile}/stream` carries a generation
26//! number, and [`ConfigServer::watch`] follows it: connect, read events,
27//! re-fetch the document when the number moves, reconnect with the
28//! `Last-Event-ID` the server left off at. The reconnect is a comparison
29//! rather than a replay — a generation subsumes every one before it — so
30//! there is no window in which a change can be missed by being reconnected
31//! past.
32//!
33//! ## What it does not do
34//!
35//! **It does not verify provenance.** The document arrives as JSON with no
36//! signature, so a client trusts the server exactly as far as TLS and the
37//! bearer token take it. A deployment that needs more should read from the
38//! store the server reads from.
39
40mod http;
41
42use std::sync::Arc;
43use std::time::Duration;
44
45use dynamic_config::{Error, Fetched, Format, Pace, RemoteSource, WatchCapability, Watching};
46use dynamic_config_store_core::attempts::Attempts;
47use dynamic_config_store_core::tls::TlsConfig;
48use dynamic_config_store_core::{guarded, redacted, LoneAuthority};
49
50use http::{Budget, Connection, Endpoint, Events, Get};
51
52/// How much of a response body is read before it is refused.
53///
54/// A configuration document that does not fit in a megabyte is not a
55/// configuration document, and a client that trusts a server to send
56/// something finite is a client that can be made to allocate until it dies.
57const MOST_BYTES: usize = 1024 * 1024;
58
59/// The default deadline for one fetch — connect, handshake, request and body.
60const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
61
62/// A [`RemoteSource`] reading one application-and-profile from a config
63/// server.
64///
65/// The credential is a bearer token, scoped by the server to the applications
66/// it may read; TLS with a private authority and a client certificate is
67/// [`TlsConfig`], the same type every store crate in this workspace takes.
68pub struct ConfigServer {
69 url: String,
70 application: String,
71 profile: String,
72 token: Option<String>,
73 token_file: Option<std::path::PathBuf>,
74 tls: TlsConfig,
75 timeout: Duration,
76 /// Built once from `tls`, on the first fetch: assembling a rustls
77 /// configuration reads files, and a fetch path is not where that belongs.
78 client: std::sync::OnceLock<Arc<rustls::ClientConfig>>,
79 described: String,
80 /// Where a watch reports an attempt that came back with nothing.
81 ///
82 /// Nobody, unless [`reporting_to`](Self::reporting_to) says otherwise —
83 /// the same door the eight store crates carry, and for the same reason:
84 /// a watch swallows transport failures by design, so without this the
85 /// only thing that knew the server had been unreachable for an hour was
86 /// the loop, and `status().reachable()` went on answering `true`.
87 attempts: Attempts,
88}
89
90impl ConfigServer {
91 /// A source reading `{url}/{application}/{profile}`.
92 ///
93 /// `url` may carry a path prefix — `https://config.internal/config` — for
94 /// a server mounted behind one. A userinfo component is refused rather
95 /// than dropped: this server's credential is a bearer token, and a
96 /// password in a url is a password in every log that url reaches.
97 #[must_use]
98 pub fn new(
99 url: impl Into<String>,
100 application: impl Into<String>,
101 profile: impl Into<String>,
102 ) -> Self {
103 let (url, application, profile) = (url.into(), application.into(), profile.into());
104
105 // Redacted as the description is built, rather than where each
106 // message is written: this string is quoted into every error this
107 // source raises and is what `describe()` returns — and one of those
108 // errors is the *refusal* of a `user:password@` authority. Printing
109 // the password while saying it is refused would be a leak with a
110 // note attached.
111 let described = format!(
112 "config server {} {application}/{profile}",
113 redacted(&url, LoneAuthority::Username)
114 );
115
116 Self {
117 // Parsing is deferred to the first fetch so that `new` cannot
118 // fail: a source that refuses to be *built* is awkward to place
119 // in a builder chain, and the url is checked before it is used.
120 url,
121 application,
122 profile,
123 token: None,
124 token_file: None,
125 tls: TlsConfig::new(),
126 timeout: DEFAULT_TIMEOUT,
127 client: std::sync::OnceLock::new(),
128 described,
129 attempts: Attempts::default(),
130 }
131 }
132
133 /// Report failed attempts to `sink`, so an outage is visible.
134 ///
135 /// A watch swallows transport failures on purpose — outliving one is
136 /// what a watch is for — and the cost of that is a store that has been
137 /// unreachable for an hour while `status().reachable()` says otherwise.
138 /// This is the door the eight store crates carry, and the same
139 /// discipline: take the sink where the watch is wired, once, because a
140 /// sink captures the generation of the source installed at that moment
141 /// and that is what fences a winding-down loop's failures away from its
142 /// replacement.
143 ///
144 /// **A failure moves the failure streak and nothing else.** The fetch
145 /// count and the clock are left alone, so a dashboard keeps ageing
146 /// `last_fetch` while `up` goes to zero — the pair an alert wants. It
147 /// changes nothing about what [`watch`](Self::watch) returns.
148 #[must_use]
149 pub fn reporting_to(mut self, sink: dynamic_config::RemoteSink) -> Self {
150 self.attempts = Attempts::to(sink);
151 self
152 }
153
154 /// The bearer token this server issued for these applications.
155 ///
156 /// Without one the server answers `401` unless it was started with
157 /// anonymous access explicitly enabled.
158 #[must_use]
159 pub fn with_token(mut self, token: impl Into<String>) -> Self {
160 self.token = Some(token.into());
161 self
162 }
163
164 /// The bearer token read from a file, **re-read at every fetch** —
165 /// for credentials something else rotates underneath this client,
166 /// first among them a pod's projected service-account token (the
167 /// server's `[kubernetes]` auth reviews exactly that). Wins over
168 /// [`with_token`](Self::with_token) when both are set.
169 #[must_use]
170 pub fn with_token_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
171 self.token_file = Some(path.into());
172 self
173 }
174
175 /// A private certificate authority, a client certificate, or both.
176 ///
177 /// The same [`TlsConfig`] the store crates take, so a deployment spells
178 /// its trust once and uses it everywhere.
179 #[must_use]
180 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
181 self.tls = tls;
182 self
183 }
184
185 /// The deadline for one fetch: connect, TLS handshake, request and body.
186 ///
187 /// Ten seconds by default. A fetch that hangs is a reload that never
188 /// happens, and the loop above has no other way to notice.
189 #[must_use]
190 pub fn with_timeout(mut self, timeout: Duration) -> Self {
191 self.timeout = timeout;
192 self
193 }
194
195 /// The rustls configuration, built once.
196 ///
197 /// Building it reads files, so it is done on the first fetch and kept —
198 /// not per fetch, and not at construction, where it would make `new`
199 /// fallible for a source that may never be used.
200 fn tls_client(&self, secure: bool) -> Result<Option<&Arc<rustls::ClientConfig>>, Error> {
201 if !secure {
202 return Ok(None);
203 }
204
205 if let Some(built) = self.client.get() {
206 return Ok(Some(built));
207 }
208
209 let built = self.build_tls_client()?;
210
211 Ok(Some(self.client.get_or_init(|| built)))
212 }
213
214 fn build_tls_client(&self) -> Result<Arc<rustls::ClientConfig>, Error> {
215 use rustls::pki_types::pem::PemObject as _;
216 use rustls::pki_types::{CertificateDer, PrivateKeyDer};
217
218 let mut roots = rustls::RootCertStore::empty();
219
220 // The platform store first, then the caller's authority on top: a
221 // private CA is one *more* certificate to trust, which is the whole
222 // reason this crate offers no way to turn verification off.
223 for certificate in rustls_native_certs::load_native_certs().certs {
224 let _ = roots.add(certificate);
225 }
226
227 if let Some(pem) = self.tls.ca_certificate_pem(&self.described)? {
228 let mut added = 0;
229
230 for certificate in CertificateDer::pem_slice_iter(&pem) {
231 let certificate = certificate.map_err(|_| {
232 Error::remote(format!(
233 "{}: the certificate authority is not readable as PEM",
234 self.described
235 ))
236 })?;
237
238 roots
239 .add(certificate)
240 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))?;
241 added += 1;
242 }
243
244 if added == 0 {
245 return Err(Error::remote(format!(
246 "{}: the certificate authority holds no certificate",
247 self.described
248 )));
249 }
250 }
251
252 let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
253
254 let Some((certificate, key)) = self.tls.client_certificate_pem(&self.described)? else {
255 return Ok(Arc::new(builder.with_no_client_auth()));
256 };
257
258 let chain = CertificateDer::pem_slice_iter(&certificate)
259 .collect::<Result<Vec<_>, _>>()
260 .map_err(|_| {
261 Error::remote(format!(
262 "{}: the client certificate is not readable as PEM",
263 self.described
264 ))
265 })?;
266
267 // The key's own parse error is deliberately dropped: the one thing
268 // such an error has to say is the line it choked on, and in a key
269 // file that line is key material.
270 let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| {
271 Error::remote(format!(
272 "{}: the client private key is not readable as PEM",
273 self.described
274 ))
275 })?;
276
277 builder
278 .with_client_auth_cert(chain, key)
279 .map(Arc::new)
280 .map_err(|error| Error::remote(format!("{}: {error}", self.described)))
281 }
282
283 /// One fetch, on the current thread's runtime.
284 async fn read(&self) -> Result<Fetched, Error> {
285 let endpoint = Endpoint::parse(&self.url, &self.described)?;
286 let path = endpoint.path(&format!("/{}/{}", self.application, self.profile));
287
288 // One budget for the whole attempt, started here: the deadline
289 // `with_timeout` documents is for a fetch, and a fetch is the
290 // connect, the handshake, the request and the body together.
291 let budget = Budget::starting(self.timeout);
292
293 let secure = endpoint.secure;
294 let mut connection =
295 Connection::open(&endpoint, self.tls_client(secure)?, budget, &self.described).await?;
296
297 // The file wins, and is read per fetch: a projected token that
298 // rotated between two fetches must present its NEW self. One
299 // reader, shared with the watch — two copies of "which credential
300 // do we present" is one more than a credential should have.
301 let bearer = self.bearer()?;
302
303 let response = connection
304 .get(
305 &endpoint,
306 Get {
307 path: &path,
308 token: bearer.as_deref(),
309 accept: "application/json",
310 resume: None,
311 },
312 budget,
313 &self.described,
314 )
315 .await?;
316
317 if !response.status().is_success() {
318 return Err(http::refused(response.status(), &self.described));
319 }
320
321 let body = http::body(response, MOST_BYTES, budget, &self.described).await?;
322 let text = String::from_utf8(body)
323 .map_err(|_| Error::remote(format!("{}: the document is not UTF-8", self.described)))?;
324
325 // The server answers `{application, profile, generation, config}`;
326 // the engine wants the document, which is `config`. Reaching for it
327 // by name rather than deserializing the envelope keeps this working
328 // when the envelope grows a field.
329 let document = extract(&text, &self.described)?;
330
331 Ok(Fetched::new(document, Format::Json))
332 }
333}
334
335/// The `config` member of the server's envelope, re-rendered.
336fn extract(text: &str, described: &str) -> Result<String, Error> {
337 let envelope: serde_json::Value = serde_json::from_str(text)
338 .map_err(|_| Error::remote(format!("{described}: the answer is not JSON")))?;
339
340 let document = envelope.get("config").ok_or_else(|| {
341 Error::remote(format!(
342 "{described}: the answer carries no `config` member; is this a \
343 config server?"
344 ))
345 })?;
346
347 serde_json::to_string(document)
348 .map_err(|_| Error::remote(format!("{described}: the document will not re-render")))
349}
350
351impl ConfigServer {
352 /// How long a stream may be silent before it is treated as dead.
353 ///
354 /// The server sends a comment every fifteen seconds precisely so that
355 /// silence means something; three of those is a connection a proxy has
356 /// dropped without telling either end.
357 const IDLE: Duration = Duration::from_secs(50);
358
359 /// Follows the change stream, fetching whenever the generation moves.
360 ///
361 /// Blocks until `watching` is stopped, so it belongs on a thread of its
362 /// own. `interval` is the reconnect pace rather than a poll: the stream
363 /// pushes, and this is how long to wait before trying again when it
364 /// ends. The waits are spread across a fleet and grow after a failure,
365 /// so a server coming back up is not met by every pod at once.
366 ///
367 /// Each document is delivered only when it differs from the last one:
368 /// a generation moves for every install, and an install that changed
369 /// nothing this caller can see should wake nothing.
370 ///
371 /// # Errors
372 ///
373 /// If `on_change` refuses a document. A connection failing is not an
374 /// error — reconnecting through an outage is what this is for.
375 pub fn watch<F>(
376 &self,
377 watching: &Watching,
378 interval: Duration,
379 mut on_change: F,
380 ) -> Result<(), Error>
381 where
382 F: FnMut(Fetched) -> Result<(), Error>,
383 {
384 // One runtime for the whole watch, unlike `fetch`'s per-call one: a
385 // watch is a long-lived thing by definition, so the argument that
386 // makes a per-call runtime free does not apply to it.
387 let runtime = tokio::runtime::Builder::new_current_thread()
388 .enable_all()
389 .build()
390 .map_err(|error| {
391 Error::remote(format!(
392 "{}: no runtime for the watch: {error}",
393 self.described
394 ))
395 })?;
396
397 // Settled once, before the loop, because neither can come right by
398 // being retried: a URL this crate cannot parse and a TLS
399 // configuration it cannot build are the caller's to fix, and a loop
400 // that swallowed them reconnected forever, delivered nothing and
401 // said nothing. The eight stores validate what is deterministic up
402 // front for the same reason.
403 let endpoint = Endpoint::parse(&self.url, &self.described)?;
404 self.tls_client(endpoint.secure)?;
405
406 runtime.block_on(async {
407 let mut pace = Pace::new(interval);
408 let mut resume: Option<String> = None;
409 let mut last: Option<Fetched> = None;
410
411 while watching.keep_going() {
412 match self
413 .subscribed(watching, &mut resume, &mut last, &mut on_change)
414 .await
415 {
416 Ok(()) => pace.succeeded(),
417 // The caller refusing a document is the one failure this
418 // loop does not own: it is a decision, not an outage.
419 Err(Ended::Refused(error)) => return Err(error),
420 // Everything else is swallowed on purpose, credentials
421 // included: a token file rotating between two
422 // connections looks exactly like a token that is wrong,
423 // and a watch that stopped on the first would be a pod
424 // that never recovered from a routine rotation.
425 Err(Ended::Disconnected) => pace.failed(),
426 }
427
428 sleep_while(watching, pace.next_wait()).await;
429 }
430
431 Ok(())
432 })
433 }
434
435 /// One connection's worth of stream, from subscribe to close.
436 async fn subscribed<F>(
437 &self,
438 watching: &Watching,
439 resume: &mut Option<String>,
440 last: &mut Option<Fetched>,
441 on_change: &mut F,
442 ) -> Result<(), Ended>
443 where
444 F: FnMut(Fetched) -> Result<(), Error>,
445 {
446 let endpoint = Endpoint::parse(&self.url, &self.described)
447 .map_err(|error| self.disconnected(&error))?;
448 let path = endpoint.path(&format!("/{}/{}/stream", self.application, self.profile));
449
450 // The budget covers getting the stream open — connect, handshake,
451 // request — and stops there. A deadline on the stream itself would
452 // be a deadline on the configuration not changing.
453 let budget = Budget::starting(self.timeout);
454 let secure = endpoint.secure;
455 let tls = self
456 .tls_client(secure)
457 .map_err(|error| self.disconnected(&error))?;
458 let mut connection = Connection::open(&endpoint, tls, budget, &self.described)
459 .await
460 .map_err(|error| self.disconnected(&error))?;
461
462 let bearer = self.bearer().map_err(|error| self.disconnected(&error))?;
463 let response = connection
464 .get(
465 &endpoint,
466 Get {
467 path: &path,
468 token: bearer.as_deref(),
469 accept: "text/event-stream",
470 resume: resume.as_deref(),
471 },
472 budget,
473 &self.described,
474 )
475 .await
476 .map_err(|error| self.disconnected(&error))?;
477
478 if !response.status().is_success() {
479 let status = response.status();
480 let refusal = http::refused(status, &self.described);
481
482 // **A 404 is an answer, not an outage.** The stream path is
483 // absent when a deployment sets `max_stream_connections = 0`,
484 // and when a URL names a prefix this server does not mount —
485 // neither comes right by reconnecting, and a loop that retried
486 // them forever was a watch that delivered nothing and said
487 // nothing. Everything else is waited out, credentials included:
488 // a token file rotating between two connections looks exactly
489 // like a token that is wrong.
490 if status == 404 {
491 return Err(Ended::Refused(refusal));
492 }
493
494 return Err(self.disconnected(&refusal));
495 }
496
497 let mut events = Events::new(response);
498
499 // Whether the *first* event of this connection is the server saying
500 // where the document stands rather than that it moved. It is,
501 // exactly when this subscription sent no `Last-Event-ID`.
502 let mut opening = resume.is_none();
503
504 while watching.keep_going() {
505 let next = events
506 .next(watching, Self::IDLE, &self.described)
507 .await
508 .map_err(|error| self.disconnected(&error))?;
509
510 let Some(event) = next else {
511 // The server closed it. Ordinary — a rolling restart does
512 // exactly this — and the loop above reconnects.
513 return Ok(());
514 };
515
516 // A keep-alive says the connection is there and nothing else.
517 // Round the loop rather than through the fetch: re-reading the
518 // whole document every fifteen seconds of quiet is the poll this
519 // client exists to replace, and coming back here is also what
520 // notices a watch that has been stopped.
521 if !event.carried {
522 continue;
523 }
524
525 // The event says *something landed*; the document is fetched
526 // from the endpoint that serves documents. Reading the number
527 // out of the payload is not needed for that and is not done:
528 // an install is an install.
529 let _ = event.data;
530
531 // **The opening event is not a change.** A first subscription
532 // sends no `Last-Event-ID`, so the server opens with where the
533 // document stands — which is the current value, and
534 // "the current value is not delivered at startup" is the
535 // contract all nine sources keep. Its id is still worth having:
536 // a reconnect resumes from it.
537 if opening {
538 opening = false;
539 *resume = event.id.or_else(|| resume.take());
540
541 continue;
542 }
543
544 let fetched = self
545 .read()
546 .await
547 .map_err(|error| self.disconnected(&error))?;
548
549 if last.as_ref() != Some(&fetched) {
550 *last = Some(fetched.clone());
551
552 // Through `guarded`, as every other store delivers: a
553 // callback that panics ends the watch with an error rather
554 // than unwinding through this loop and killing the caller's
555 // thread with the `RemoteWatch` handle still looking alive.
556 guarded(on_change, fetched, &self.described).map_err(Ended::Refused)?;
557 }
558
559 // **Advanced last, and only on the way out.** Moving it before
560 // the fetch meant a fetch that failed still counted: the
561 // reconnect carried a `Last-Event-ID` for a generation this
562 // client never read, the server saw nothing newer, and the
563 // change was lost until the next install — the one window the
564 // module documentation says cannot exist.
565 if let Some(id) = event.id {
566 *resume = Some(id);
567 }
568 }
569
570 Ok(())
571 }
572
573 /// An attempt that came back with nothing, reported and then forgotten.
574 ///
575 /// Reporting happens here rather than at each call site so that a
576 /// failure branch added later cannot be the one that forgets — the
577 /// same shape the eight store crates use.
578 fn disconnected(&self, error: &Error) -> Ended {
579 self.attempts.failed(error);
580
581 Ended::Disconnected
582 }
583
584 /// The bearer token to present, file first.
585 fn bearer(&self) -> Result<Option<String>, Error> {
586 match &self.token_file {
587 Some(file) => std::fs::read_to_string(file)
588 .map(|token| Some(token.trim().to_owned()))
589 .map_err(|error| {
590 Error::auth(format!(
591 "{}: reading the bearer token file: {error}",
592 self.described
593 ))
594 }),
595 None => Ok(self.token.clone()),
596 }
597 }
598}
599
600/// Why one connection's worth of stream ended.
601///
602/// The distinction the loop above acts on, and the only one it needs: a
603/// connection that failed is waited out and tried again, and a caller that
604/// refused a document has made a decision the loop has no business
605/// overriding.
606enum Ended {
607 /// The connection failed, or the server refused the subscription. The
608 /// error is not carried past here: the loop waits and tries again, and a
609 /// message per reconnect through an outage is a log nobody can read. It
610 /// *is* reported first — see `ConfigServer::disconnected`.
611 Disconnected,
612 Refused(Error),
613}
614
615/// Sleeps for `total`, waking early once the watch is stopped.
616async fn sleep_while(watching: &Watching, total: Duration) {
617 const SLICE: Duration = Duration::from_millis(250);
618
619 let mut left = total;
620
621 while left > Duration::ZERO && watching.keep_going() {
622 let slice = left.min(SLICE);
623
624 tokio::time::sleep(slice).await;
625 left -= slice;
626 }
627}
628
629impl RemoteSource for ConfigServer {
630 fn fetch(&self) -> Result<Fetched, Error> {
631 // A blocking `fetch` on a client built from an async stack: one
632 // runtime, current-thread, for this call only. A source is fetched
633 // when a caller asks, minutes or hours apart, so the cost of starting
634 // one is not on any path that matters — and owning a long-lived
635 // runtime here would put a second one inside applications that
636 // already have theirs.
637 let runtime = tokio::runtime::Builder::new_current_thread()
638 .enable_all()
639 .build()
640 .map_err(|error| {
641 Error::remote(format!(
642 "{}: no runtime for the fetch: {error}",
643 self.described
644 ))
645 })?;
646
647 runtime.block_on(self.read())
648 }
649
650 fn describe(&self) -> String {
651 self.described.clone()
652 }
653
654 /// Native: the server pushes a generation down a `text/event-stream`.
655 fn watch_capability(&self) -> WatchCapability {
656 WatchCapability::Native
657 }
658
659 fn watch(
660 &self,
661 watching: &Watching,
662 interval: Duration,
663 on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
664 ) -> Result<(), Error> {
665 ConfigServer::watch(self, watching, interval, on_change)
666 }
667}
668
669impl std::fmt::Debug for ConfigServer {
670 /// Shape only. The token is the credential and never prints; `TlsConfig`
671 /// redacts its own key material; and the URL is redacted too, because a
672 /// `user:password@` authority is refused at fetch time rather than at
673 /// construction — so a source carrying one can be printed.
674 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 formatter
676 .debug_struct("ConfigServer")
677 .field("url", &redacted(&self.url, LoneAuthority::Username))
678 .field("application", &self.application)
679 .field("profile", &self.profile)
680 .field("token", &self.token.as_ref().map(|_| "<redacted>"))
681 .field("token_file", &self.token_file)
682 .field("tls", &self.tls)
683 .field("timeout", &self.timeout)
684 .finish()
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn a_document_is_lifted_out_of_the_servers_envelope() {
694 let text = r#"{"application":"billing","profile":"prod","generation":7,
695 "config":{"port":8080}}"#;
696
697 assert_eq!(extract(text, "a server").unwrap(), r#"{"port":8080}"#);
698 }
699
700 #[test]
701 fn an_answer_from_something_that_is_not_a_config_server_says_so() {
702 let error = extract(r#"{"hello":"world"}"#, "a server").unwrap_err();
703
704 assert!(error.to_string().contains("no `config` member"), "{error}");
705 }
706
707 /// A password in the URL is refused rather than sent — and the refusal
708 /// must not be where it gets printed. `new` cannot fail, so the source
709 /// exists, is `Debug`-printed and describes itself long before the
710 /// parser gets to say no.
711 #[test]
712 fn a_password_in_the_url_reaches_neither_debug_nor_a_message() {
713 let source = ConfigServer::new(
714 "https://user:hunter2-do-not-print@config.internal",
715 "billing",
716 "prod",
717 );
718
719 let rendered = format!("{source:?}");
720 assert!(!rendered.contains("hunter2"), "{rendered}");
721
722 let described = source.describe();
723 assert!(!described.contains("hunter2"), "{described}");
724 assert!(described.contains("user:***@"), "{described}");
725
726 // And the refusal itself, which quotes the description.
727 let error = Endpoint::parse(&source.url, &source.described)
728 .expect_err("a `user:password@` authority is refused");
729 assert!(!error.to_string().contains("hunter2"), "{error}");
730 }
731
732 #[test]
733 fn a_token_never_reaches_debug() {
734 let source = ConfigServer::new("https://config.internal", "billing", "prod")
735 .with_token("hunter2-do-not-print");
736
737 let rendered = format!("{source:?}");
738
739 assert!(!rendered.contains("hunter2"), "{rendered}");
740 assert!(rendered.contains("<redacted>"), "{rendered}");
741 }
742}