hls_runtime/client/tokio_client.rs
1//! `TokioClient` — a tokio + reqwest IO adapter driving
2//! [`crate::client::HlsClient`] over real HTTP (issue #717 slice 5).
3//!
4//! Feature-gated behind `tokio` (NOT default): the sans-IO core in
5//! `engine.rs` has zero dependency on tokio, reqwest, a socket, or a clock —
6//! this module is a thin async shell that performs the actual HTTP GETs the
7//! core's [`crate::client::Action`]s describe (playlist reload, incl. blocking
8//! `_HLS_msn`/`_HLS_part`; resource fetch, incl. `Range` byte-ranges) and
9//! feeds the responses back into the core, looping until the caller stops
10//! polling or the stream ends.
11//!
12//! # Auth
13//!
14//! [`TokioClientConfig::auth`] takes a [`broadcast_auth::Credentials`] — the
15//! same shared scheme-agnostic model `rtsp-runtime` and `multimux`'s HTTP
16//! input adapters (`source::http_auth`) use (issue #663 P3b/P3c). Basic
17//! (RFC 7617) and Bearer (RFC 6750) are pre-applied on every request via
18//! reqwest's own request-builder helpers (`RequestBuilder::basic_auth`/
19//! `bearer_auth`) — no challenge round-trip needed. Digest (RFC 7616) is not
20//! something reqwest supports natively: the first request for a given
21//! resource is sent bare, and only if the server answers `401` with a
22//! `WWW-Authenticate` challenge does [`TokioClient`] compute the
23//! `Authorization` response via [`broadcast_auth::Authenticator`] and resend
24//! once — the resulting authenticator is then cached and applied
25//! preemptively to every subsequent request for as long as it keeps being
26//! accepted (RFC 7616 §3.3's `nc` advances across those calls), so a live
27//! pull doesn't round-trip a fresh challenge on every single fetch.
28//!
29//! # Error recovery
30//!
31//! - A **resource** (init/part/segment) fetch that keeps failing is retried
32//! up to [`TokioClientConfig::max_resource_retries`] times with capped
33//! exponential backoff, then [`crate::client::HlsClient::on_error`] is
34//! called and the adapter moves on — the sans-IO core un-marks that
35//! resource as "requested", so the *next* playlist reload naturally
36//! re-requests it (see `engine.rs`'s `on_error` docs). One flaky fetch
37//! never stalls the whole client.
38//! - A **playlist** reload has no such fallback in the sans-IO core — unlike
39//! a resource, [`crate::client::HlsClient::on_error`] with `None` does not
40//! re-queue anything (there is no "next reload" to fall back to; the
41//! *current* reload IS the mechanism that discovers what to fetch next).
42//! So a playlist fetch is retried indefinitely with capped backoff
43//! ([`TokioClientConfig::retry_backoff`]/[`TokioClientConfig::max_retry_backoff`])
44//! rather than ever giving up — a caller wanting a hard ceiling on how long
45//! [`TokioClient::next_output`] may block should wrap it in
46//! `tokio::time::timeout` itself.
47
48use std::time::Duration;
49
50use broadcast_auth::{Authenticator, Credentials, RequestContext};
51use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
52use reqwest::{Client, StatusCode};
53
54use super::{Action, HlsClient, Output, ResourceId};
55
56/// Errors from the tokio IO adapter itself — distinct from
57/// [`crate::client::Error`], the sans-IO core's own parse/demux error type
58/// (wrapped here via
59/// [`TokioError::Client`]).
60#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum TokioError {
63 /// The underlying HTTP request failed outright (connect/timeout/TLS/
64 /// transport error) — never a non-2xx response, see [`Self::Status`].
65 #[error("HTTP request to {url} failed: {source}")]
66 Http {
67 /// The request URL.
68 url: String,
69 /// The underlying reqwest error.
70 #[source]
71 source: reqwest::Error,
72 },
73 /// The server returned a non-success HTTP status.
74 #[error("HTTP {status} fetching {url}")]
75 Status {
76 /// The request URL.
77 url: String,
78 /// The response status.
79 status: reqwest::StatusCode,
80 },
81 /// The sans-IO core rejected the fetched playlist/resource — see
82 /// [`crate::client::Error`] for the underlying reason.
83 #[error(transparent)]
84 Client(#[from] super::Error),
85 /// A `401`'s `WWW-Authenticate` challenge could not be parsed, or no
86 /// `Authorization` response could be computed from it (e.g. an
87 /// unsupported Digest `algorithm`/`qop`) — see [`broadcast_auth::Error`].
88 #[error("auth challenge/response failed: {0}")]
89 Auth(#[from] broadcast_auth::Error),
90 /// Building the `Range` header for `url` would overflow `u64`
91 /// (`offset + (length - 1)`, RFC 8216bis §4.4.4.9). In normal operation
92 /// this never fires: [`crate::client::HlsClient`]'s internal
93 /// `resolve_byte_range` already rejects any byte range whose
94 /// `offset + length` overflows before it ever reaches this adapter (see
95 /// `crate::client::Error::ByteRangeOverflow`) — this variant is
96 /// defense-in-depth against a future/foreign caller constructing a
97 /// `(u64, u64)` byte range directly, bypassing the sans-IO core.
98 #[error("byte range for {url} overflows u64: offset {offset} + length {length}")]
99 ByteRangeOverflow {
100 /// The request URL the range applies to.
101 url: String,
102 /// The range's starting offset.
103 offset: u64,
104 /// The range's length.
105 length: u64,
106 },
107}
108
109/// Tunables for [`TokioClient`]. [`Default`] gives sane values for a
110/// well-behaved LL-HLS origin reachable over a real (or loopback) network.
111#[derive(Debug, Clone)]
112pub struct TokioClientConfig {
113 /// Per-request timeout for a plain (non-blocking) playlist GET, or a
114 /// resource (init/part/segment) GET.
115 pub request_timeout: Duration,
116 /// Per-request timeout for a **blocking** Playlist Reload
117 /// (`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) — must exceed the
118 /// origin's own blocking hold time (e.g. `multimux`'s `LlHlsOutput` caps
119 /// at 5s) with headroom, since the origin is expected to hold the
120 /// response open until new content exists or its own cap elapses.
121 pub blocking_timeout: Duration,
122 /// How many times a **resource** fetch is retried (capped exponential
123 /// backoff) before the adapter gives up on that specific fetch and moves
124 /// on (see the module docs' "Error recovery" section). A playlist reload
125 /// is never subject to this cap — it retries indefinitely.
126 pub max_resource_retries: u32,
127 /// Initial backoff between retry attempts; doubles per attempt up to
128 /// [`Self::max_retry_backoff`].
129 pub retry_backoff: Duration,
130 /// Ceiling the doubled [`Self::retry_backoff`] is capped at.
131 pub max_retry_backoff: Duration,
132 /// Optional auth attached to every request (see the module docs).
133 pub auth: Option<Credentials>,
134}
135
136impl Default for TokioClientConfig {
137 fn default() -> Self {
138 Self {
139 request_timeout: Duration::from_secs(5),
140 blocking_timeout: Duration::from_secs(10),
141 max_resource_retries: 3,
142 retry_backoff: Duration::from_millis(200),
143 max_retry_backoff: Duration::from_secs(2),
144 auth: None,
145 }
146 }
147}
148
149/// Diagnostic counters for what [`TokioClient`] has actually done —
150/// distinguishing "parsed an LL-HLS tag" from "acted on it", the same bar
151/// this crate's own acceptance tests hold the adapter to (issue #717's
152/// "blocking-reload + preload-hint prefetch actually exercised" acceptance
153/// item). Also useful to a real caller for observability.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
155pub struct TokioClientStats {
156 /// Playlist GETs performed (blocking + non-blocking).
157 pub playlist_fetches: u64,
158 /// Of those, how many carried `_HLS_msn`/`_HLS_part` — a Blocking
159 /// Playlist Reload (RFC 8216bis §6.2.5.2).
160 pub blocking_reloads: u64,
161 /// Resource (init/part/segment) GETs performed.
162 pub resource_fetches: u64,
163 /// Of those, how many were for the exact URL most recently named by the
164 /// playlist's `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3) — i.e.
165 /// fetched ahead of that resource's own numbered (`#EXT-X-PART`)
166 /// appearance, not merely alongside it.
167 pub preload_hint_resource_fetches: u64,
168}
169
170/// An async shell driving [`crate::client::HlsClient`] over real HTTP.
171///
172/// ```no_run
173/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
174/// use hls_runtime::client::tokio_client::TokioClient;
175///
176/// let mut client = TokioClient::new("http://127.0.0.1:8080/live/media.m3u8")?;
177/// while let Some(output) = client.next_output().await? {
178/// // Hand `output` (Init / Samples / Discontinuity / EndOfStream) to a decoder.
179/// let _ = output;
180/// }
181/// # Ok(())
182/// # }
183/// ```
184pub struct TokioClient {
185 core: HlsClient,
186 http: Client,
187 config: TokioClientConfig,
188 playlist_url: String,
189 stats: TokioClientStats,
190 last_preload_hint_url: Option<String>,
191 ended: bool,
192 /// Cached from the most recent Digest `401` challenge/response (see the
193 /// module docs' "Auth" section) — `None` until the first Digest
194 /// challenge is answered, or when [`TokioClientConfig::auth`] isn't
195 /// [`Credentials::Digest`]. Applied preemptively to every subsequent
196 /// request once set, advancing `nc` (RFC 7616 §3.3) each time.
197 digest_authenticator: Option<Authenticator>,
198}
199
200impl TokioClient {
201 /// Build a client for the Media Playlist at `playlist_url`, with
202 /// [`TokioClientConfig::default`] tunables.
203 ///
204 /// # Errors
205 /// Only if the underlying `reqwest::Client` fails to build (e.g. TLS
206 /// backend initialisation failure) — not a network error, since no
207 /// request has been made yet.
208 pub fn new(playlist_url: impl Into<String>) -> Result<Self, TokioError> {
209 Self::with_config(playlist_url, TokioClientConfig::default())
210 }
211
212 /// Build a client with explicit [`TokioClientConfig`] tunables.
213 ///
214 /// # Errors
215 /// See [`Self::new`].
216 pub fn with_config(
217 playlist_url: impl Into<String>,
218 config: TokioClientConfig,
219 ) -> Result<Self, TokioError> {
220 let playlist_url = playlist_url.into();
221 let http = Client::builder()
222 .build()
223 .map_err(|source| TokioError::Http {
224 url: playlist_url.clone(),
225 source,
226 })?;
227 Ok(Self {
228 core: HlsClient::new(playlist_url.clone()),
229 http,
230 config,
231 playlist_url,
232 stats: TokioClientStats::default(),
233 last_preload_hint_url: None,
234 ended: false,
235 digest_authenticator: None,
236 })
237 }
238
239 /// Diagnostic counters for requests actually made so far — see
240 /// [`TokioClientStats`].
241 pub fn stats(&self) -> TokioClientStats {
242 self.stats
243 }
244
245 /// Drive the sans-IO core — performing whatever HTTP its next
246 /// [`Action`] needs, retrying transient failures per
247 /// [`TokioClientConfig`] — until at least one [`Output`] is available.
248 ///
249 /// Returns `Ok(None)` once, right after [`Output::EndOfStream`] has
250 /// already been yielded by a previous call, signalling the caller to
251 /// stop polling. A live stream that never sends `#EXT-X-ENDLIST` simply
252 /// never returns `Ok(None)` — the caller drives this in a loop (or a
253 /// `tokio::select!` alongside its own shutdown signal) for as long as it
254 /// wants to keep playing.
255 ///
256 /// # Errors
257 /// [`TokioError::Client`] if the sans-IO core rejects a fetched
258 /// playlist/resource (malformed playlist, demux failure). Resource fetch
259 /// failures are retried/recovered internally (see the module docs) and
260 /// never surface here; a playlist fetch failure retries indefinitely
261 /// rather than ever returning an [`TokioError::Http`]/[`TokioError::Status`]
262 /// — see the module docs' "Error recovery" section.
263 pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
264 loop {
265 if let Some(out) = self.core.next_output() {
266 if matches!(out, Output::EndOfStream) {
267 self.ended = true;
268 }
269 return Ok(Some(out));
270 }
271 if self.ended {
272 return Ok(None);
273 }
274
275 match self.core.poll() {
276 Some(action @ Action::FetchPlaylist { .. }) => {
277 let request_url = action
278 .playlist_request_url()
279 .expect("Action::FetchPlaylist always has a playlist_request_url");
280 let is_blocking = matches!(
281 action,
282 Action::FetchPlaylist {
283 blocking: Some(_),
284 ..
285 }
286 );
287 let timeout = if is_blocking {
288 self.config.blocking_timeout
289 } else {
290 self.config.request_timeout
291 };
292 let bytes = self.fetch_playlist_resilient(&request_url, timeout).await;
293 self.stats.playlist_fetches += 1;
294 if is_blocking {
295 self.stats.blocking_reloads += 1;
296 }
297 self.note_preload_hint(&bytes);
298 self.core.on_playlist(&bytes)?;
299 }
300 Some(Action::FetchResource {
301 id,
302 url,
303 byte_range,
304 }) => match self.fetch_resource_bounded(&url, byte_range).await {
305 Ok(bytes) => {
306 self.stats.resource_fetches += 1;
307 if matches!(id, ResourceId::Part { .. })
308 && self.last_preload_hint_url.as_deref() == Some(url.as_str())
309 {
310 self.stats.preload_hint_resource_fetches += 1;
311 }
312 self.core.on_resource(id, &bytes)?;
313 }
314 Err(_source) => {
315 // Retries exhausted: un-mark as requested so the
316 // next playlist reload naturally re-requests it,
317 // rather than stalling the whole client on one bad
318 // fetch.
319 self.core.on_error(Some(id));
320 }
321 },
322 Some(Action::WaitMs(ms)) => {
323 tokio::time::sleep(Duration::from_millis(ms)).await;
324 }
325 None => {
326 // Defensive-only: in normal operation `on_playlist`
327 // always re-queues a reload (or `WaitMs`) before
328 // returning, for as long as the stream hasn't ended, so
329 // this should never actually spin. Guard against it
330 // anyway with a short sleep rather than a hot loop.
331 tokio::time::sleep(Duration::from_millis(10)).await;
332 }
333 }
334 }
335 }
336
337 /// Re-parse a just-fetched playlist purely to note its
338 /// `#EXT-X-PRELOAD-HINT` URL for [`TokioClientStats`] bookkeeping — the
339 /// sans-IO core does its own, authoritative parse independently inside
340 /// [`crate::client::HlsClient::on_playlist`]; this is a deliberate, small,
341 /// side-channel duplication (stats only, never fed back into scheduling)
342 /// rather than growing an API on the core to expose its parsed state.
343 fn note_preload_hint(&mut self, playlist_bytes: &[u8]) {
344 self.last_preload_hint_url = core::str::from_utf8(playlist_bytes)
345 .ok()
346 .and_then(|text| broadcast_hls::MediaPlaylist::parse(text).ok())
347 .and_then(|pl| pl.low_latency)
348 .and_then(|ll| ll.preload_hint_part)
349 .map(|hint| super::url::resolve(&self.playlist_url, &hint));
350 }
351
352 /// Applies whatever auth can be attached *before* sending — Basic/Bearer
353 /// always (they never need a challenge), plus a cached Digest
354 /// [`Authenticator`] once one exists (advancing `nc`). A `Digest`
355 /// config with no cached authenticator yet is left unauthenticated for
356 /// this attempt; [`Self::fetch_bytes`] answers the resulting `401`.
357 fn apply_auth_preemptive(
358 &mut self,
359 req: reqwest::RequestBuilder,
360 method: &str,
361 uri: &str,
362 ) -> reqwest::RequestBuilder {
363 match &self.config.auth {
364 Some(Credentials::Basic { username, password }) => {
365 req.basic_auth(username, Some(password))
366 }
367 Some(Credentials::Bearer { token }) => req.bearer_auth(token),
368 Some(_) => {
369 // `Credentials::Digest` (or any future non_exhaustive
370 // variant): no preemptive header without a cached
371 // authenticator from a prior challenge.
372 if let Some(auth) = self.digest_authenticator.as_mut()
373 && let Ok(value) = auth.authorization(&RequestContext::new(method, uri))
374 {
375 return req.header(AUTHORIZATION, value);
376 }
377 req
378 }
379 None => req,
380 }
381 }
382
383 /// Answers a `401` response by computing the `Authorization` value from
384 /// its `WWW-Authenticate` challenge (via [`broadcast_auth`]) and
385 /// resending once — only when [`TokioClientConfig::auth`] is
386 /// [`Credentials::Digest`] (Basic/Bearer are already pre-applied and a
387 /// `401` for those means wrong credentials, not a missing challenge
388 /// round-trip). The freshly built [`Authenticator`] is cached on
389 /// success so later requests apply it preemptively.
390 async fn retry_after_unauthorized(
391 &mut self,
392 method: &str,
393 uri: &str,
394 req: reqwest::RequestBuilder,
395 response: reqwest::Response,
396 ) -> Result<reqwest::Response, TokioError> {
397 let Some(creds @ Credentials::Digest { .. }) = self.config.auth.clone() else {
398 return Ok(response);
399 };
400 let Some(challenge) = response
401 .headers()
402 .get(WWW_AUTHENTICATE)
403 .and_then(|v| v.to_str().ok())
404 .map(str::to_string)
405 else {
406 return Ok(response);
407 };
408 let mut authenticator = Authenticator::from_challenge(&challenge, creds)?;
409 let value = authenticator.authorization(&RequestContext::new(method, uri))?;
410 self.digest_authenticator = Some(authenticator);
411 req.header(AUTHORIZATION, value)
412 .send()
413 .await
414 .map_err(|source| TokioError::Http {
415 url: uri.to_string(),
416 source,
417 })
418 }
419
420 async fn fetch_bytes(
421 &mut self,
422 url: &str,
423 byte_range: Option<(u64, u64)>,
424 timeout: Duration,
425 ) -> Result<Vec<u8>, TokioError> {
426 let req = self.apply_auth_preemptive(
427 build_request(&self.http, url, byte_range, timeout)?,
428 "GET",
429 url,
430 );
431 let resp = req.send().await.map_err(|source| TokioError::Http {
432 url: url.to_string(),
433 source,
434 })?;
435
436 let resp = if resp.status() == StatusCode::UNAUTHORIZED {
437 let retry_req = build_request(&self.http, url, byte_range, timeout)?;
438 self.retry_after_unauthorized("GET", url, retry_req, resp)
439 .await?
440 } else {
441 resp
442 };
443
444 let status = resp.status();
445 if !status.is_success() {
446 return Err(TokioError::Status {
447 url: url.to_string(),
448 status,
449 });
450 }
451 let bytes = resp.bytes().await.map_err(|source| TokioError::Http {
452 url: url.to_string(),
453 source,
454 })?;
455 Ok(bytes.to_vec())
456 }
457
458 /// Retry a playlist fetch indefinitely (capped exponential backoff) —
459 /// see the module docs' "Error recovery" section for why a playlist
460 /// reload, unlike a resource fetch, has no bounded-retry fallback.
461 async fn fetch_playlist_resilient(&mut self, url: &str, timeout: Duration) -> Vec<u8> {
462 let mut backoff = self.config.retry_backoff;
463 loop {
464 match self.fetch_bytes(url, None, timeout).await {
465 Ok(bytes) => return bytes,
466 Err(_source) => {
467 tokio::time::sleep(backoff).await;
468 backoff = (backoff * 2).min(self.config.max_retry_backoff);
469 }
470 }
471 }
472 }
473
474 /// Retry a resource fetch up to [`TokioClientConfig::max_resource_retries`]
475 /// times (capped exponential backoff), then give up.
476 async fn fetch_resource_bounded(
477 &mut self,
478 url: &str,
479 byte_range: Option<(u64, u64)>,
480 ) -> Result<Vec<u8>, TokioError> {
481 let mut backoff = self.config.retry_backoff;
482 let mut last_err = None;
483 for _ in 0..self.config.max_resource_retries.max(1) {
484 match self
485 .fetch_bytes(url, byte_range, self.config.request_timeout)
486 .await
487 {
488 Ok(bytes) => return Ok(bytes),
489 Err(source) => {
490 last_err = Some(source);
491 tokio::time::sleep(backoff).await;
492 backoff = (backoff * 2).min(self.config.max_retry_backoff);
493 }
494 }
495 }
496 Err(last_err.expect("loop runs at least once (max_resource_retries.max(1))"))
497 }
498}
499
500/// Builds a plain (unauthenticated) GET request for `url`, with an optional
501/// `Range` header (RFC 8216bis §4.4.4.9 partial-part byte ranges) — factored
502/// out so [`TokioClient::fetch_bytes`] can build a fresh, independent request
503/// both for the first attempt and for the post-`401` Digest retry (a
504/// `reqwest::RequestBuilder` is consumed by `.send()`, so the retry can't
505/// reuse the first one).
506fn build_request(
507 client: &Client,
508 url: &str,
509 byte_range: Option<(u64, u64)>,
510 timeout: Duration,
511) -> Result<reqwest::RequestBuilder, TokioError> {
512 let mut req = client.get(url).timeout(timeout);
513 if let Some((offset, length)) = byte_range {
514 // `saturating_sub` here only guards the `length == 0` underflow
515 // case (`0 - 1`) — it must never silently saturate the *addition*
516 // below, since a saturated `end` would produce a `Range:` header
517 // for the wrong bytes rather than failing this one request. See
518 // `TokioError::ByteRangeOverflow`.
519 let end =
520 offset
521 .checked_add(length.saturating_sub(1))
522 .ok_or(TokioError::ByteRangeOverflow {
523 url: url.to_string(),
524 offset,
525 length,
526 })?;
527 req = req.header(reqwest::header::RANGE, format!("bytes={offset}-{end}"));
528 }
529 Ok(req)
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 // Biting test for the u64-overflow defect (issue's `tokio_client.rs:497`
537 // finding): `build_request`'s old `offset + length.saturating_sub(1)`
538 // guarded only the `-1` underflow, not the addition itself. A byte range
539 // whose `offset` is near `u64::MAX` — reachable via a malicious/corrupt
540 // playlist's `BYTERANGE`, or a long-lived omitted-offset cursor walked
541 // up by `crate::client::HlsClient` — must be rejected, not panic (debug)
542 // or silently wrap to a bogus `Range:` header naming the wrong bytes
543 // (release).
544 //
545 // MUTATION VERIFIED: reverting to the original
546 // `let end = offset + length.saturating_sub(1);` (dropping
547 // `checked_add`/`Err`) makes this test fail: the debug build panics with
548 // "attempt to add with overflow" inside `build_request` before
549 // `expect_err` ever runs (confirmed by running it), rather than
550 // returning `TokioError::ByteRangeOverflow`. Recompiled and re-ran to
551 // observe that exact panic, then restored the checked_add.
552 #[test]
553 fn build_request_rejects_a_range_whose_end_overflows_u64() {
554 let client = Client::new();
555 let err = build_request(
556 &client,
557 "http://example.com/seg.m4s",
558 Some((u64::MAX - 1, 5)),
559 Duration::from_secs(5),
560 )
561 .expect_err("offset near u64::MAX + length must overflow and be rejected");
562 assert!(
563 matches!(
564 err,
565 TokioError::ByteRangeOverflow {
566 offset,
567 length: 5,
568 ..
569 } if offset == u64::MAX - 1
570 ),
571 "wrong error variant/fields: {err:?}"
572 );
573 }
574
575 // The flip side: an ordinary, non-overflowing byte range must still
576 // produce the expected `Range:` header — the overflow guard must not
577 // have broken the common case.
578 #[test]
579 fn build_request_sets_range_header_for_a_normal_byte_range() {
580 let client = Client::new();
581 let req = build_request(
582 &client,
583 "http://example.com/seg.m4s",
584 Some((10, 20)),
585 Duration::from_secs(5),
586 )
587 .expect("a normal byte range must succeed");
588 let built = req.build().expect("request builds");
589 let header = built
590 .headers()
591 .get(reqwest::header::RANGE)
592 .expect("Range header must be present");
593 assert_eq!(header, "bytes=10-29");
594 }
595
596 // Security-blocker regression (pre-release audit): `TokioClientConfig`
597 // derives `Debug` and embeds `Option<Credentials>` directly (`auth`) — it
598 // must inherit `Credentials`'s redacting `Debug`, never the raw secret.
599 #[test]
600 fn tokio_client_config_debug_does_not_leak_embedded_credentials_secret() {
601 let config = TokioClientConfig {
602 auth: Some(Credentials::new("admin", "a-very-secret-password")),
603 ..TokioClientConfig::default()
604 };
605 let debug = format!("{config:?}");
606 assert!(
607 !debug.contains("a-very-secret-password"),
608 "leaked via TokioClientConfig Debug: {debug}"
609 );
610 assert!(debug.contains("***"), "expected redaction marker: {debug}");
611 }
612}