Skip to main content

vector_core/
net.rs

1//! Network utilities — SSRF protection, HTTP client helpers.
2
3use url::Url;
4
5/// Reject URLs that resolve to private/loopback/link-local addresses (SSRF protection).
6pub fn validate_url_not_private(url_str: &str) -> Result<(), &'static str> {
7    let parsed = Url::parse(url_str).map_err(|_| "Invalid URL")?;
8
9    match parsed.scheme() {
10        "http" | "https" => {}
11        _ => return Err("Only HTTP(S) URLs are allowed"),
12    }
13
14    match parsed.host() {
15        Some(url::Host::Ipv4(ip)) => {
16            let o = ip.octets();
17            if ip.is_loopback() || ip.is_private() || ip.is_link_local()
18                || ip.is_broadcast() || ip.is_unspecified()
19                || (o[0] == 100 && o[1] >= 64 && o[1] <= 127)
20            {
21                return Err("Private/internal IP addresses are not allowed");
22            }
23        }
24        Some(url::Host::Ipv6(ip)) => {
25            if ip.is_loopback() || ip.is_unspecified() || is_ipv6_private(&ip) {
26                return Err("Private/internal IP addresses are not allowed");
27            }
28        }
29        Some(url::Host::Domain(domain)) => {
30            if domain == "localhost" || domain.ends_with(".local") || domain.ends_with(".internal") {
31                return Err("Local hostnames are not allowed");
32            }
33        }
34        None => return Err("URL has no host"),
35    }
36
37    Ok(())
38}
39
40fn is_ipv6_private(ip: &std::net::Ipv6Addr) -> bool {
41    if let Some(ipv4) = ip.to_ipv4_mapped() {
42        return ipv4.is_loopback() || ipv4.is_private() || ipv4.is_link_local();
43    }
44    let segments = ip.segments();
45    if segments[0] & 0xfe00 == 0xfc00 { return true; } // Unique local
46    if segments[0] & 0xffc0 == 0xfe80 { return true; } // Link-local
47    false
48}
49
50/// The exact `reqwest` these clients are built from.
51///
52/// Re-exported because [`build_http_client`] hands back a `reqwest::Client`: a
53/// consumer that names its own `reqwest` version gets a different type with the
54/// same name, and the error says nothing useful about why.
55pub use reqwest;
56
57/// Build an HTTP client with the given timeout.
58///
59/// Honors the Tor failsafe: when the user has Tor enabled, every connection
60/// goes through Tor — period. If Tor is enabled but not currently running
61/// (bootstrap in flight, mid-restart, service crashed), the returned client
62/// is wired to a blackhole SOCKS proxy so requests fail at the TCP layer
63/// without any chance of leaking clearnet traffic. Direct connections are
64/// only ever issued when the user has explicitly disabled Tor.
65///
66/// Callers should use this rather than `reqwest::Client::builder()` directly
67/// so the failsafe automatically covers their traffic. The `disallowed_methods`
68/// clippy lint enforces this everywhere except this one canonical call site.
69#[allow(clippy::disallowed_methods)]
70pub fn build_http_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
71    build_http_client_with_options(timeout, None, true)
72}
73
74/// Like `build_http_client`, optionally without redirect-following.
75/// Blossom PUT uses `false`: a 3xx mid-upload would re-issue as GET and
76/// drop the body, so we surface the 3xx as the real failure status.
77#[allow(clippy::disallowed_methods)]
78pub fn build_http_client_with_options(
79    timeout: std::time::Duration,
80    read_timeout: Option<std::time::Duration>,
81    follow_redirects: bool,
82) -> Result<reqwest::Client, String> {
83    let mut builder = reqwest::Client::builder()
84        .timeout(timeout)
85        // Bounded connect: a black-holed host (SYN swallowed, never refused) must
86        // fail in seconds instead of silently consuming the whole request budget.
87        .connect_timeout(std::time::Duration::from_secs(15));
88    // Idle read timeout (opt-in): a server that accepts the connection but streams
89    // nothing back for this long is treated as dead, so upload failover moves on fast
90    // instead of waiting out the whole `timeout`. It resets on every received byte, so
91    // a slow-but-progressing transfer survives. Left None for large uploads, whose
92    // server can legitimately go quiet while it stores the blob.
93    if let Some(rt) = read_timeout {
94        builder = builder.read_timeout(rt);
95    }
96    if !follow_redirects {
97        builder = builder.redirect(reqwest::redirect::Policy::none());
98    } else {
99        // Validate EVERY redirect hop, not just the initial URL: a public
100        // host answering `302 Location: http://169.254.169.254/…` would
101        // otherwise walk the request straight past the SSRF check.
102        builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
103            if attempt.previous().len() >= 10 {
104                return attempt.error("too many redirects");
105            }
106            match validate_url_not_private(attempt.url().as_str()) {
107                Ok(()) => attempt.follow(),
108                Err(e) => attempt.error(e),
109            }
110        }));
111    }
112
113    #[cfg(feature = "tor")]
114    {
115        match crate::tor::transport_state() {
116            crate::tor::TorTransportState::Active(addr) => {
117                // Use the addr from the variant directly — re-querying via
118                // proxy_url() races against TorService::stop() and can panic.
119                let url = format!("socks5h://{addr}");
120                let proxy = reqwest::Proxy::all(&url)
121                    .map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
122                builder = builder.proxy(proxy);
123                // Circuit builds to a fresh host legitimately take tens of seconds.
124                builder = builder.connect_timeout(std::time::Duration::from_secs(45));
125            }
126            crate::tor::TorTransportState::RequiredButInactive => {
127                // Tor failsafe: route to a blackhole so connections fail safe
128                // instead of leaking direct.
129                let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
130                let proxy = reqwest::Proxy::all(&url)
131                    .map_err(|e| format!("blackhole proxy invalid: {e}"))?;
132                builder = builder.proxy(proxy);
133            }
134            crate::tor::TorTransportState::Disabled => {
135                // No proxy — user has Tor off.
136            }
137        }
138    }
139
140    builder
141        .build()
142        .map_err(|e| format!("Failed to build HTTP client: {}", e))
143}
144
145// ============================================================================
146// Shared HTTP client — proxy-aware + rebuildable on Tor toggle
147// ============================================================================
148//
149// Some call sites (image-cache fetches, PIVX wallet polling) make frequent
150// requests and benefit from a shared `reqwest::Client` to reuse connection
151// pools / TLS sessions. A bare `LazyLock<Client>` doesn't work for us because
152// a Tor toggle should affect future requests immediately — but the static is
153// frozen at first init. Instead, we hold an `Arc<Client>` behind a `RwLock`
154// and rebuild it via `rebuild_shared_http_client()` whenever the Tor state
155// changes. In-flight requests finish on the old Arc; new requests pick up
156// the new one.
157
158use std::sync::{Arc, OnceLock, RwLock};
159
160static SHARED_HTTP_CLIENT: OnceLock<RwLock<Arc<reqwest::Client>>> = OnceLock::new();
161
162const DEFAULT_SHARED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
163
164fn shared_cell() -> &'static RwLock<Arc<reqwest::Client>> {
165    SHARED_HTTP_CLIENT.get_or_init(|| {
166        let client = build_http_client(DEFAULT_SHARED_TIMEOUT)
167            .expect("initial shared HTTP client build cannot fail");
168        RwLock::new(Arc::new(client))
169    })
170}
171
172/// Get a shared HTTP client. Cheap clone (Arc), proxy-aware, picks up Tor
173/// toggles on the next call after `rebuild_shared_http_client()` runs.
174pub fn shared_http_client() -> Arc<reqwest::Client> {
175    shared_cell().read().unwrap().clone()
176}
177
178/// Rebuild the shared client. Call this when Tor state flips so the next
179/// request goes through the freshly-configured proxy. In-flight requests on
180/// the old client continue to completion on the previous Arc.
181pub fn rebuild_shared_http_client() -> Result<(), String> {
182    let new = Arc::new(build_http_client(DEFAULT_SHARED_TIMEOUT)?);
183    *shared_cell().write().unwrap() = new;
184    Ok(())
185}
186
187/// Find the byte index where a bracket/paren group opened at `start` closes,
188/// tracking nesting depth and honoring backslash escapes — markdown balances
189/// both, so a naive first-closer scan desyncs on `[[claim]](evil)` or
190/// `[claim\]](evil)` and lets the claim reach the URL scan. All compared
191/// bytes are ASCII, so the returned index is char-boundary-safe.
192fn md_group_close(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
193    let mut depth = 1usize;
194    let mut escaped = false;
195    let mut j = start;
196    loop {
197        match bytes.get(j).copied() {
198            None => return None,
199            Some(b'\\') if !escaped => escaped = true,
200            Some(b) if b == open && !escaped => depth += 1,
201            Some(b) if b == close && !escaped => {
202                depth -= 1;
203                if depth == 0 {
204                    return Some(j);
205                }
206            }
207            _ => escaped = false,
208        }
209        j += 1;
210    }
211}
212
213/// Rewrite markdown links so a preview-URL scan sees only real DESTINATIONS:
214/// `[text](href)` keeps the href and drops the display text — a URL claimed in
215/// the text must never win the OG preview over where the link actually goes —
216/// `[text](<href>)` drops entirely (angle brackets are the no-preview syntax),
217/// and `[text][ref]` drops the label (its destination is a definition scanned
218/// on its own elsewhere in the text). Images (`![alt](url)`) render as literal
219/// text in chat, so they pass through untouched.
220pub fn strip_md_link_claims(text: &str) -> String {
221    let bytes = text.as_bytes();
222    let mut out = String::with_capacity(text.len());
223    let mut last = 0;
224    let mut i = 0;
225    while i < bytes.len() {
226        if bytes[i] != b'[' || (i > 0 && bytes[i - 1] == b'!') {
227            i += 1;
228            continue;
229        }
230        let Some(close) = md_group_close(bytes, i + 1, b'[', b']') else { break };
231        match bytes.get(close + 1).copied() {
232            // Inline link: drop the label, contribute the destination.
233            Some(b'(') => {
234                let Some(paren) = md_group_close(bytes, close + 2, b'(', b')') else {
235                    i = close + 1;
236                    continue;
237                };
238                out.push_str(&text[last..i]);
239                let href = text[close + 2..paren].trim();
240                if !(href.starts_with('<') && href.ends_with('>')) {
241                    out.push(' ');
242                    out.push_str(href);
243                    out.push(' ');
244                }
245                i = paren + 1;
246                last = i;
247            }
248            // Reference link: drop the label; the `[ref]: url` definition line
249            // carries the real destination and gets scanned as plain text.
250            Some(b'[') => {
251                let Some(ref_close) = md_group_close(bytes, close + 2, b'[', b']') else {
252                    i = close + 1;
253                    continue;
254                };
255                out.push_str(&text[last..i]);
256                i = ref_close + 1;
257                last = i;
258            }
259            _ => {
260                i = close + 1;
261            }
262        }
263    }
264    out.push_str(&text[last..]);
265    out
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // ========================================================================
273    // strip_md_link_claims — preview scan must see destinations, not claims
274    // ========================================================================
275
276    #[test]
277    fn md_link_claim_text_dropped_href_kept() {
278        // The spoof shape: claimed URL first in raw text, real destination second.
279        let out = strip_md_link_claims("[https://your-bank.com](https://evil.io)");
280        assert!(!out.contains("your-bank.com"), "claimed text must not reach the scan: {out}");
281        assert!(out.contains("https://evil.io"), "real destination must reach the scan: {out}");
282    }
283
284    #[test]
285    fn md_no_preview_link_dropped_entirely() {
286        let out = strip_md_link_claims("see [docs](<https://vector.app/docs>) ok");
287        assert!(!out.contains("vector.app"), "no-preview href must not reach the scan: {out}");
288        assert!(out.contains("see ") && out.contains(" ok"));
289    }
290
291    #[test]
292    fn md_image_passes_through() {
293        let text = "![shot](https://host.io/img.png)";
294        assert_eq!(strip_md_link_claims(text), text);
295    }
296
297    #[test]
298    fn plain_text_and_bare_urls_untouched() {
299        let text = "check https://vector.app and [also] (spaced) brackets";
300        assert_eq!(strip_md_link_claims(text), text);
301    }
302
303    #[test]
304    fn multiple_links_keep_document_order() {
305        let out = strip_md_link_claims("[a](https://one.io) mid [b](https://two.io)");
306        let one = out.find("https://one.io").expect("first href kept");
307        let two = out.find("https://two.io").expect("second href kept");
308        assert!(one < two);
309    }
310
311    #[test]
312    fn nested_bracket_label_still_drops_claim() {
313        let out = strip_md_link_claims("[[https://trusted.com]](https://evil.io)");
314        assert!(!out.contains("trusted.com"), "nested-bracket claim must not reach the scan: {out}");
315        assert!(out.contains("https://evil.io"));
316    }
317
318    #[test]
319    fn escaped_bracket_label_still_drops_claim() {
320        let out = strip_md_link_claims(r"[https://trusted.com\]](https://evil.io)");
321        assert!(!out.contains("trusted.com"), "escaped-bracket claim must not reach the scan: {out}");
322        assert!(out.contains("https://evil.io"));
323    }
324
325    #[test]
326    fn paren_path_href_survives_whole() {
327        let out = strip_md_link_claims("[wiki](https://en.wikipedia.org/wiki/Foo_(bar))");
328        assert!(out.contains("https://en.wikipedia.org/wiki/Foo_(bar)"), "balanced-paren href kept intact: {out}");
329    }
330
331    #[test]
332    fn reference_link_label_dropped_definition_scanned() {
333        let out = strip_md_link_claims("[https://trusted.com][1]\n[1]: https://evil.io");
334        assert!(!out.contains("trusted.com"), "reflink claim must not reach the scan: {out}");
335        assert!(out.contains("https://evil.io"), "definition URL stays scannable: {out}");
336    }
337
338    #[test]
339    fn multibyte_label_no_panic() {
340        let out = strip_md_link_claims("[🔒 sécurisé — café](https://evil.io) 日本語");
341        assert!(out.contains("https://evil.io"));
342        assert!(out.contains("日本語"));
343    }
344
345    // ========================================================================
346    // Valid public URLs — should pass
347    // ========================================================================
348
349    #[test]
350    fn valid_public_https_url_passes() {
351        assert!(validate_url_not_private("https://example.com/path").is_ok(),
352            "https://example.com should be allowed");
353    }
354
355    #[test]
356    fn valid_public_http_url_passes() {
357        assert!(validate_url_not_private("http://example.com").is_ok(),
358            "http://example.com should be allowed");
359    }
360
361    #[test]
362    fn valid_public_ip_8888_passes() {
363        assert!(validate_url_not_private("https://8.8.8.8/dns").is_ok(),
364            "8.8.8.8 (Google DNS) is a public IP and should be allowed");
365    }
366
367    #[test]
368    fn valid_public_ip_1111_passes() {
369        assert!(validate_url_not_private("https://1.1.1.1").is_ok(),
370            "1.1.1.1 (Cloudflare DNS) is a public IP and should be allowed");
371    }
372
373    #[test]
374    fn valid_url_with_port_passes() {
375        assert!(validate_url_not_private("https://example.com:8080/api").is_ok(),
376            "URL with port on public domain should be allowed");
377    }
378
379    // ========================================================================
380    // Loopback addresses — should be rejected
381    // ========================================================================
382
383    #[test]
384    fn localhost_rejected() {
385        let result = validate_url_not_private("http://localhost/secret");
386        assert!(result.is_err(), "localhost should be rejected");
387    }
388
389    #[test]
390    fn ip_127_0_0_1_rejected() {
391        let result = validate_url_not_private("http://127.0.0.1/admin");
392        assert!(result.is_err(), "127.0.0.1 (loopback) should be rejected");
393    }
394
395    #[test]
396    fn ip_127_255_255_255_rejected() {
397        let result = validate_url_not_private("http://127.255.255.255");
398        assert!(result.is_err(), "127.255.255.255 (loopback range) should be rejected");
399    }
400
401    // ========================================================================
402    // Private IP ranges — should be rejected
403    // ========================================================================
404
405    #[test]
406    fn private_class_a_10_rejected() {
407        let result = validate_url_not_private("http://10.0.0.1/internal");
408        assert!(result.is_err(), "10.0.0.1 (private class A) should be rejected");
409    }
410
411    #[test]
412    fn private_class_b_172_16_rejected() {
413        let result = validate_url_not_private("http://172.16.0.1/internal");
414        assert!(result.is_err(), "172.16.0.1 (private class B) should be rejected");
415    }
416
417    #[test]
418    fn private_class_b_172_31_rejected() {
419        let result = validate_url_not_private("http://172.31.255.255");
420        assert!(result.is_err(), "172.31.255.255 (private class B upper bound) should be rejected");
421    }
422
423    #[test]
424    fn private_class_c_192_168_rejected() {
425        let result = validate_url_not_private("http://192.168.1.1/router");
426        assert!(result.is_err(), "192.168.1.1 (private class C) should be rejected");
427    }
428
429    // ========================================================================
430    // Special addresses — should be rejected
431    // ========================================================================
432
433    #[test]
434    fn link_local_169_254_rejected() {
435        let result = validate_url_not_private("http://169.254.1.1");
436        assert!(result.is_err(), "169.254.1.1 (link-local) should be rejected");
437    }
438
439    #[test]
440    fn cgn_100_64_rejected() {
441        let result = validate_url_not_private("http://100.64.0.1");
442        assert!(result.is_err(), "100.64.0.1 (CGN / shared address space) should be rejected");
443    }
444
445    #[test]
446    fn cgn_100_127_rejected() {
447        let result = validate_url_not_private("http://100.127.255.255");
448        assert!(result.is_err(), "100.127.255.255 (CGN upper bound) should be rejected");
449    }
450
451    #[test]
452    fn broadcast_255_rejected() {
453        let result = validate_url_not_private("http://255.255.255.255");
454        assert!(result.is_err(), "255.255.255.255 (broadcast) should be rejected");
455    }
456
457    #[test]
458    fn unspecified_0_0_0_0_rejected() {
459        let result = validate_url_not_private("http://0.0.0.0");
460        assert!(result.is_err(), "0.0.0.0 (unspecified) should be rejected");
461    }
462
463    // ========================================================================
464    // IPv6 addresses — should be rejected
465    // ========================================================================
466
467    #[test]
468    fn ipv6_loopback_rejected() {
469        let result = validate_url_not_private("http://[::1]/secret");
470        assert!(result.is_err(), "::1 (IPv6 loopback) should be rejected");
471    }
472
473    #[test]
474    fn ipv6_unique_local_fc00_rejected() {
475        let result = validate_url_not_private("http://[fc00::1]");
476        assert!(result.is_err(), "fc00::1 (IPv6 unique-local) should be rejected");
477    }
478
479    #[test]
480    fn ipv6_unique_local_fd00_rejected() {
481        let result = validate_url_not_private("http://[fd00::1]");
482        assert!(result.is_err(), "fd00::1 (IPv6 unique-local) should be rejected");
483    }
484
485    #[test]
486    fn ipv6_link_local_fe80_rejected() {
487        let result = validate_url_not_private("http://[fe80::1]");
488        assert!(result.is_err(), "fe80::1 (IPv6 link-local) should be rejected");
489    }
490
491    #[test]
492    fn ipv4_mapped_ipv6_loopback_rejected() {
493        let result = validate_url_not_private("http://[::ffff:127.0.0.1]");
494        assert!(result.is_err(), "::ffff:127.0.0.1 (IPv4-mapped loopback) should be rejected");
495    }
496
497    #[test]
498    fn ipv4_mapped_ipv6_private_rejected() {
499        let result = validate_url_not_private("http://[::ffff:192.168.1.1]");
500        assert!(result.is_err(), "::ffff:192.168.1.1 (IPv4-mapped private) should be rejected");
501    }
502
503    // ========================================================================
504    // Domain name restrictions
505    // ========================================================================
506
507    #[test]
508    fn dot_local_domain_rejected() {
509        let result = validate_url_not_private("http://mydevice.local/api");
510        assert!(result.is_err(), ".local domain should be rejected");
511    }
512
513    #[test]
514    fn dot_internal_domain_rejected() {
515        let result = validate_url_not_private("http://service.internal/health");
516        assert!(result.is_err(), ".internal domain should be rejected");
517    }
518
519    // ========================================================================
520    // Scheme restrictions
521    // ========================================================================
522
523    #[test]
524    fn ftp_scheme_rejected() {
525        let result = validate_url_not_private("ftp://example.com/file.txt");
526        assert!(result.is_err(), "ftp:// scheme should be rejected");
527        assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
528    }
529
530    #[test]
531    fn file_scheme_rejected() {
532        let result = validate_url_not_private("file:///etc/passwd");
533        assert!(result.is_err(), "file:// scheme should be rejected");
534        assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
535    }
536
537    #[test]
538    fn javascript_scheme_rejected() {
539        let result = validate_url_not_private("javascript:alert(1)");
540        assert!(result.is_err(), "javascript: scheme should be rejected");
541    }
542
543    #[test]
544    fn data_scheme_rejected() {
545        let result = validate_url_not_private("data:text/html,<h1>hi</h1>");
546        assert!(result.is_err(), "data: scheme should be rejected");
547    }
548
549    // ========================================================================
550    // Missing / invalid URL
551    // ========================================================================
552
553    #[test]
554    fn no_host_rejected() {
555        // http:// with no host is actually an invalid URL for the url crate
556        let result = validate_url_not_private("http://");
557        assert!(result.is_err(), "URL with no host should be rejected");
558    }
559
560    #[test]
561    fn invalid_url_rejected() {
562        let result = validate_url_not_private("not a url at all");
563        assert!(result.is_err(), "invalid URL string should be rejected");
564        assert_eq!(result.unwrap_err(), "Invalid URL");
565    }
566
567    #[test]
568    fn empty_string_rejected() {
569        let result = validate_url_not_private("");
570        assert!(result.is_err(), "empty string should be rejected");
571    }
572
573    // ========================================================================
574    // Edge cases
575    // ========================================================================
576
577    #[test]
578    fn cgn_100_63_not_rejected() {
579        // 100.63.x.x is NOT in the CGN range (100.64-100.127)
580        assert!(validate_url_not_private("http://100.63.255.255").is_ok(),
581            "100.63.255.255 is outside CGN range and should be allowed");
582    }
583
584    #[test]
585    fn cgn_100_128_not_rejected() {
586        // 100.128.x.x is NOT in the CGN range
587        assert!(validate_url_not_private("http://100.128.0.1").is_ok(),
588            "100.128.0.1 is outside CGN range and should be allowed");
589    }
590
591    #[test]
592    fn private_172_15_not_rejected() {
593        // 172.15.x.x is NOT private (private is 172.16-172.31)
594        assert!(validate_url_not_private("http://172.15.255.255").is_ok(),
595            "172.15.255.255 is outside private class B range and should be allowed");
596    }
597
598    #[test]
599    fn private_172_32_not_rejected() {
600        // 172.32.x.x is NOT private
601        assert!(validate_url_not_private("http://172.32.0.1").is_ok(),
602            "172.32.0.1 is outside private class B range and should be allowed");
603    }
604}
605
606// ============================================================================
607// Remote File Size
608// ============================================================================
609
610/// Get the size of a remote file via HEAD request or Range fallback.
611/// Returns None if the URL is private, unreachable, or size can't be determined.
612pub async fn get_remote_file_size(url: &str) -> Option<u64> {
613    validate_url_not_private(url).ok()?;
614    let client = build_http_client(std::time::Duration::from_secs(8)).ok()?;
615
616    // Method 1: HEAD request
617    if let Ok(head_res) = client.head(url).send().await {
618        if let Some(length) = head_res.content_length() {
619            if length > 0 {
620                return Some(length);
621            }
622        }
623    }
624
625    // Method 2: Range request fallback
626    if let Ok(partial_res) = client
627        .get(url)
628        .header("Range", "bytes=0-1")
629        .send()
630        .await
631    {
632        if let Some(content_range) = partial_res.headers().get("content-range") {
633            if let Ok(range_str) = content_range.to_str() {
634                if let Some(size_part) = range_str.split('/').nth(1) {
635                    if let Ok(size) = size_part.parse::<u64>() {
636                        return Some(size);
637                    }
638                }
639            }
640        }
641        if let Some(length) = partial_res.content_length() {
642            if length > 100 {
643                return Some(length);
644            }
645        }
646    }
647
648    None
649}