Skip to main content

kithara_net/
types.rs

1use std::{cmp::min, collections::HashMap, fmt};
2
3use bitflags::bitflags;
4use bon::Builder;
5use kithara_bufpool::BytePool;
6use kithara_platform::{time::Duration, traits::FromWithParams};
7
8bitflags! {
9    /// HTTP `Accept-Encoding` algorithms the client advertises and is
10    /// willing to decode. Reqwest auto-adds the corresponding
11    /// `Accept-Encoding` header for every algorithm whose flag is set;
12    /// the rest are disabled via `ClientBuilder::no_*` so the wire
13    /// header stays in lockstep with this set.
14    ///
15    /// Subset selection matters when talking to anti-bot WAFs that
16    /// fingerprint clients by their exact `Accept-Encoding` value.
17    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
18    pub struct Compression: u8 {
19        const GZIP    = 1 << 0;
20        const DEFLATE = 1 << 1;
21        const BROTLI  = 1 << 2;
22        const ZSTD    = 1 << 3;
23    }
24}
25
26/// TLS+HTTP fingerprint the native `client-wreq` backend impersonates. The
27/// DRM keyserver sits behind an anti-bot WAF that fingerprints the TLS
28/// `ClientHello` (JA3) and 418-rejects non-browser stacks; presenting a real
29/// browser fingerprint via `wreq` is what gets a 200. Default `Safari` matches
30/// iOS `URLSession`; Android selects a different preset. Inert under the
31/// `client-reqwest` backend and on wasm32 (no emulation; the browser fetch
32/// already carries a real fingerprint).
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum ImpersonatePreset {
36    #[default]
37    Safari,
38    Chrome,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct Headers {
43    inner: HashMap<String, String>,
44}
45
46impl Headers {
47    #[must_use]
48    // ast-grep-ignore: style.prefer-default-derive
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn get(&self, key: &str) -> Option<&str> {
54        self.inner.get(key).map(String::as_str)
55    }
56
57    pub fn insert<K: Into<String>, V: Into<String>>(&mut self, key: K, value: V) {
58        self.inner.insert(key.into(), value.into());
59    }
60
61    #[must_use]
62    pub fn is_empty(&self) -> bool {
63        self.inner.is_empty()
64    }
65
66    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
67        self.inner.iter().map(|(k, v)| (k.as_str(), v.as_str()))
68    }
69}
70
71impl From<HashMap<String, String>> for Headers {
72    fn from(map: HashMap<String, String>) -> Self {
73        Self { inner: map }
74    }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct RangeSpec {
79    pub end: Option<u64>,
80    pub start: u64,
81}
82
83impl RangeSpec {
84    #[must_use]
85    pub fn new(start: u64, end: Option<u64>) -> Self {
86        Self { end, start }
87    }
88}
89
90impl fmt::Display for RangeSpec {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self.end {
93            Some(end) => write!(f, "bytes={}-{}", self.start, end),
94            None => write!(f, "bytes={}-", self.start),
95        }
96    }
97}
98
99#[derive(Clone, Debug, Builder)]
100#[non_exhaustive]
101pub struct RetryPolicy {
102    #[builder(default = Duration::from_millis(100))]
103    pub base_delay: Duration,
104    #[builder(default = Duration::from_secs(5))]
105    pub max_delay: Duration,
106    #[builder(default = 3)]
107    pub max_retries: u32,
108}
109
110impl Default for RetryPolicy {
111    fn default() -> Self {
112        Self::builder().build()
113    }
114}
115
116impl RetryPolicy {
117    #[must_use]
118    pub fn new(max_retries: u32, base_delay: Duration, max_delay: Duration) -> Self {
119        Self {
120            base_delay,
121            max_delay,
122            max_retries,
123        }
124    }
125
126    #[must_use]
127    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
128        const BACKOFF_BASE: u32 = 2;
129
130        if attempt == 0 {
131            return Duration::ZERO;
132        }
133        let exponential_delay = self.base_delay * BACKOFF_BASE.pow(attempt.saturating_sub(1));
134        min(exponential_delay, self.max_delay)
135    }
136}
137
138#[derive(Clone, Debug, Builder)]
139#[non_exhaustive]
140pub struct NetOptions {
141    /// `Accept-Encoding` algorithms the client offers and auto-decodes.
142    /// Defaults to all four (`gzip | deflate | brotli | zstd`); narrow it
143    /// when an upstream rejects the full set (anti-bot WAFs that
144    /// fingerprint on the exact `Accept-Encoding` string are a common
145    /// reason).
146    #[builder(default = Compression::all())]
147    pub compression: Compression,
148    /// Maximum allowed inactivity between consecutive read operations.
149    /// Maps to [`reqwest::ClientBuilder::read_timeout`] (documented as
150    /// "The timeout applies to each read operation, and resets after a
151    /// successful read") and also drives the Downloader-layer
152    /// `BodyStream` chunk-inactivity guard for the same semantics one
153    /// layer down.
154    ///
155    /// Protects against zombie connections that send headers but then
156    /// stop streaming bytes. Does **not** cap the total request
157    /// lifetime; a legitimately slow stream that keeps delivering
158    /// chunks (even one byte every few seconds) is not aborted.
159    /// Default 30s is sized to absorb realistic mobile-network stalls
160    /// (TCP retransmits, captive-portal warm-up, server-side TTFB
161    /// spikes) without aborting valid slow streams — the player's
162    /// contract is "wait for the segment, regardless of connection
163    /// speed", and a 10s cap raced real fixtures.
164    #[builder(default = Duration::from_secs(30))]
165    pub inactivity_timeout: Duration,
166    /// Hard cap on total request lifetime. Maps to
167    /// [`reqwest::RequestBuilder::timeout`]. `None` lets streaming
168    /// downloads run indefinitely as long as `inactivity_timeout` is
169    /// satisfied — required for the player to honour "wait for the
170    /// segment, regardless of connection speed". Default `Some(2 min)`
171    /// keeps a safety net against pathological cases (server stuck in
172    /// mid-body without ever closing) while not racing realistic
173    /// slow-network seeks.
174    pub total_timeout: Option<Duration>,
175    #[builder(default)]
176    pub retry_policy: RetryPolicy,
177    /// Accept invalid TLS certificates (self-signed, expired, wrong hostname).
178    /// **Security risk** — use only for local development and test servers.
179    #[builder(default)]
180    pub is_insecure: bool,
181    /// Max idle connections per host. Enables HTTP keep-alive connection
182    /// reuse, reducing `TIME_WAIT` accumulation under high request volume.
183    /// Set to 0 to disable pooling.
184    #[builder(default = 8)]
185    pub pool_max_idle_per_host: usize,
186    /// Apple `NSURLSession` streaming body queue capacity, measured in
187    /// delivered data chunks waiting for Rust consumption. Set to 0 to disable
188    /// `URLSession` task suspension for queued body chunks.
189    #[builder(default = 32)]
190    pub body_queue_capacity: usize,
191    /// Queue length at or below which a suspended Apple streaming task resumes.
192    /// Values greater than or equal to [`Self::body_queue_capacity`] are valid:
193    /// they resume as soon as the consumer drains one chunk.
194    #[builder(default = 16)]
195    pub body_queue_resume_at: usize,
196    /// Shared byte buffer pool used by backends that must copy platform-owned
197    /// response buffers before handing bytes to Rust consumers.
198    pub byte_pool: Option<BytePool>,
199    /// Browser TLS+HTTP2 fingerprint the native `client-wreq` backend
200    /// impersonates. Defaults to `Safari`. Ignored by the `client-reqwest`
201    /// backend and on wasm32 (no emulation there).
202    #[builder(default)]
203    pub impersonate: ImpersonatePreset,
204}
205
206impl Default for NetOptions {
207    fn default() -> Self {
208        Self::builder()
209            .total_timeout(Duration::from_secs(120))
210            .build()
211    }
212}
213
214impl FromWithParams<Self, Option<BytePool>> for NetOptions {
215    fn build(options: Self, byte_pool: Option<BytePool>) -> Self {
216        Self::builder()
217            .compression(options.compression)
218            .inactivity_timeout(options.inactivity_timeout)
219            .maybe_total_timeout(options.total_timeout)
220            .retry_policy(options.retry_policy)
221            .is_insecure(options.is_insecure)
222            .pool_max_idle_per_host(options.pool_max_idle_per_host)
223            .body_queue_capacity(options.body_queue_capacity)
224            .body_queue_resume_at(options.body_queue_resume_at)
225            .maybe_byte_pool(options.byte_pool.or(byte_pool))
226            .impersonate(options.impersonate)
227            .build()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    mod kithara {
234        pub(crate) use kithara_test_macros::test;
235    }
236
237    use super::*;
238
239    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
240    #[case::empty_headers(Headers::new(), true)]
241    #[case::headers_with_values({
242        let mut h = Headers::new();
243        h.insert("key1", "value1");
244        h.insert("key2", "value2");
245        h
246    }, false)]
247    async fn test_headers_is_empty(#[case] headers: Headers, #[case] expected_empty: bool) {
248        assert_eq!(headers.is_empty(), expected_empty);
249    }
250
251    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
252    #[case::simple_key("key1", "value1")]
253    #[case::content_type("Content-Type", "application/json")]
254    #[case::custom_header("X-Custom-Header", "custom-value")]
255    async fn test_headers_insert_and_get(#[case] key: &str, #[case] value: &str) {
256        let mut headers = Headers::new();
257        headers.insert(key, value);
258
259        assert_eq!(headers.get(key), Some(value));
260        assert_eq!(headers.get("non-existent"), None);
261    }
262
263    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
264    async fn test_headers_iter() {
265        let mut headers = Headers::new();
266        headers.insert("key1", "value1");
267        headers.insert("key2", "value2");
268        headers.insert("key3", "value3");
269
270        let mut iterated = HashMap::new();
271        for (k, v) in headers.iter() {
272            iterated.insert(k.to_string(), v.to_string());
273        }
274
275        assert_eq!(iterated.len(), 3);
276        assert_eq!(iterated.get("key1"), Some(&"value1".to_string()));
277        assert_eq!(iterated.get("key2"), Some(&"value2".to_string()));
278        assert_eq!(iterated.get("key3"), Some(&"value3".to_string()));
279    }
280
281    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
282    async fn test_headers_from_hashmap() {
283        let mut map = HashMap::new();
284        map.insert("key1".to_string(), "value1".to_string());
285        map.insert("key2".to_string(), "value2".to_string());
286
287        let headers: Headers = map.into();
288
289        assert!(!headers.is_empty());
290        assert_eq!(headers.get("key1"), Some("value1"));
291        assert_eq!(headers.get("key2"), Some("value2"));
292    }
293
294    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
295    async fn test_headers_default() {
296        let headers = Headers::default();
297        assert!(headers.is_empty());
298    }
299
300    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
301    #[case::full_range(0, Some(100), "bytes=0-100")]
302    #[case::open_ended(50, None, "bytes=50-")]
303    #[case::single_byte(10, Some(10), "bytes=10-10")]
304    #[case::zero_length(0, Some(0), "bytes=0-0")]
305    async fn test_range_spec_to_header_value(
306        #[case] start: u64,
307        #[case] end: Option<u64>,
308        #[case] expected_header: &str,
309    ) {
310        let range = RangeSpec::new(start, end);
311        assert_eq!(range.to_string(), expected_header);
312    }
313
314    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
315    #[case::equal_ranges(RangeSpec::new(0, Some(100)), RangeSpec::new(0, Some(100)), true)]
316    #[case::different_starts(RangeSpec::new(0, Some(100)), RangeSpec::new(1, Some(100)), false)]
317    #[case::different_ends(RangeSpec::new(0, Some(100)), RangeSpec::new(0, Some(99)), false)]
318    #[case::one_open_ended(RangeSpec::new(0, None), RangeSpec::new(0, None), true)]
319    #[case::mixed_ends(RangeSpec::new(0, Some(100)), RangeSpec::new(0, None), false)]
320    async fn test_range_spec_partial_eq(
321        #[case] range1: RangeSpec,
322        #[case] range2: RangeSpec,
323        #[case] expected_equal: bool,
324    ) {
325        assert_eq!(range1 == range2, expected_equal);
326    }
327
328    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
329    async fn test_range_spec_debug() {
330        let range = RangeSpec::new(10, Some(20));
331        let debug_output = format!("{:?}", range);
332        assert!(debug_output.contains("RangeSpec"));
333        assert!(debug_output.contains("start: 10"));
334        assert!(debug_output.contains("end: Some(20)"));
335    }
336
337    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
338    async fn test_range_spec_clone() {
339        let range1 = RangeSpec::new(10, Some(20));
340        let range2 = range1.clone();
341
342        assert_eq!(range1, range2);
343        assert_eq!(range1.start, range2.start);
344        assert_eq!(range1.end, range2.end);
345    }
346
347    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
348    async fn test_retry_policy_default() {
349        let policy = RetryPolicy::default();
350
351        assert_eq!(policy.max_retries, 3);
352        assert_eq!(policy.base_delay, Duration::from_millis(100));
353        assert_eq!(policy.max_delay, Duration::from_secs(5));
354    }
355
356    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
357    #[case(1, Duration::from_millis(50), Duration::from_secs(1))]
358    #[case(5, Duration::from_millis(100), Duration::from_secs(2))]
359    #[case(10, Duration::from_millis(200), Duration::from_secs(10))]
360    async fn test_retry_policy_new(
361        #[case] max_retries: u32,
362        #[case] base_delay: Duration,
363        #[case] max_delay: Duration,
364    ) {
365        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
366
367        assert_eq!(policy.max_retries, max_retries);
368        assert_eq!(policy.base_delay, base_delay);
369        assert_eq!(policy.max_delay, max_delay);
370    }
371
372    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
373    #[case(0, Duration::ZERO)]
374    #[case(1, Duration::from_millis(100))]
375    #[case(2, Duration::from_millis(200))]
376    #[case(3, Duration::from_millis(400))]
377    #[case(4, Duration::from_millis(800))]
378    #[case(5, Duration::from_millis(1600))]
379    #[case(10, Duration::from_secs(5))]
380    #[case(20, Duration::from_secs(5))]
381    async fn test_retry_policy_delay_for_attempt_default(
382        #[case] attempt: u32,
383        #[case] expected_delay: Duration,
384    ) {
385        let policy = RetryPolicy::default();
386        let delay = policy.delay_for_attempt(attempt);
387
388        assert_eq!(delay, expected_delay);
389    }
390
391    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
392    #[case(
393        1,
394        Duration::from_millis(50),
395        Duration::from_millis(200),
396        0,
397        Duration::ZERO
398    )]
399    #[case(
400        1,
401        Duration::from_millis(50),
402        Duration::from_millis(200),
403        1,
404        Duration::from_millis(50)
405    )]
406    #[case(
407        1,
408        Duration::from_millis(50),
409        Duration::from_millis(200),
410        2,
411        Duration::from_millis(100)
412    )]
413    #[case(
414        1,
415        Duration::from_millis(50),
416        Duration::from_millis(200),
417        3,
418        Duration::from_millis(200)
419    )]
420    #[case(
421        1,
422        Duration::from_millis(50),
423        Duration::from_millis(200),
424        4,
425        Duration::from_millis(200)
426    )]
427    async fn test_retry_policy_delay_for_attempt_custom(
428        #[case] max_retries: u32,
429        #[case] base_delay: Duration,
430        #[case] max_delay: Duration,
431        #[case] attempt: u32,
432        #[case] expected_delay: Duration,
433    ) {
434        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
435        let delay = policy.delay_for_attempt(attempt);
436
437        assert_eq!(delay, expected_delay);
438    }
439
440    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
441    async fn test_retry_policy_debug() {
442        let policy = RetryPolicy::default();
443        let debug_output = format!("{:?}", policy);
444
445        assert!(debug_output.contains("RetryPolicy"));
446        assert!(debug_output.contains("max_retries: 3"));
447        assert!(debug_output.contains("base_delay"));
448        assert!(debug_output.contains("max_delay"));
449    }
450
451    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
452    async fn test_retry_policy_clone() {
453        let policy1 = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(2));
454        let policy2 = policy1.clone();
455
456        assert_eq!(policy1.max_retries, policy2.max_retries);
457        assert_eq!(policy1.base_delay, policy2.base_delay);
458        assert_eq!(policy1.max_delay, policy2.max_delay);
459    }
460
461    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
462    #[case::start_equals_end(10, Some(10), "bytes=10-10")]
463    #[case::start_greater_than_end(20, Some(10), "bytes=20-10")]
464    #[case::max_values(u64::MAX, Some(u64::MAX), &format!("bytes={}-{}", u64::MAX, u64::MAX))]
465    async fn test_range_spec_edge_cases(
466        #[case] start: u64,
467        #[case] end: Option<u64>,
468        #[case] expected_header: &str,
469    ) {
470        let range = RangeSpec::new(start, end);
471        assert_eq!(range.to_string(), expected_header);
472    }
473
474    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
475    #[case::zero_max_retries(0, Duration::from_millis(100), Duration::from_secs(1))]
476    #[case::large_max_retries(100, Duration::from_millis(10), Duration::from_secs(10))]
477    #[case::zero_base_delay(3, Duration::ZERO, Duration::from_secs(1))]
478    #[case::zero_max_delay(3, Duration::from_millis(100), Duration::ZERO)]
479    async fn test_retry_policy_edge_cases(
480        #[case] max_retries: u32,
481        #[case] base_delay: Duration,
482        #[case] max_delay: Duration,
483    ) {
484        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
485
486        for attempt in 0..=5 {
487            let delay = policy.delay_for_attempt(attempt);
488
489            assert!(delay >= Duration::ZERO);
490
491            assert!(delay <= max_delay);
492
493            if base_delay == Duration::ZERO {
494                assert_eq!(delay, Duration::ZERO);
495            }
496        }
497    }
498
499    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
500    #[case(10)]
501    #[case(20)]
502    async fn test_retry_policy_large_attempts(#[case] attempt: u32) {
503        let policy = RetryPolicy::default();
504
505        let delay = policy.delay_for_attempt(attempt);
506
507        assert!(delay <= policy.max_delay);
508        assert!(delay >= Duration::ZERO);
509    }
510
511    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
512    #[case::with_spaces("X-Custom Header", "value with spaces")]
513    #[case::with_unicode("X-Emoji", "🎉")]
514    #[case::with_special_chars("X-Special", "a\tb\nc")]
515    #[case::empty_value("X-Empty", "")]
516    async fn test_headers_special_characters(#[case] key: &str, #[case] value: &str) {
517        let mut headers = Headers::new();
518        headers.insert(key, value);
519
520        assert_eq!(headers.get(key), Some(value));
521    }
522
523    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
524    async fn test_headers_case_sensitive() {
525        let mut headers = Headers::new();
526        headers.insert("Content-Type", "application/json");
527        headers.insert("content-type", "text/plain");
528
529        assert_eq!(headers.get("Content-Type"), Some("application/json"));
530        assert_eq!(headers.get("content-type"), Some("text/plain"));
531        assert_ne!(headers.get("Content-Type"), headers.get("content-type"));
532    }
533}