Skip to main content

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}