Skip to main content

reqwest_rotate/
proxy.rs

1//! Round-robin proxy rotation with a cooldown for proxies that recently
2//! failed.
3
4use std::collections::HashSet;
5use std::fmt;
6use std::sync::{Mutex, MutexGuard, PoisonError};
7use std::time::{Duration, Instant};
8
9use crate::error::Error;
10
11/// A pool of proxy URLs rotated round-robin, with a cooldown applied to
12/// proxies that were recently marked bad (e.g. after a connect failure or a
13/// `407` response from the proxy itself).
14///
15/// URLs are validated and canonicalised on construction: a bare
16/// `host:port` becomes `http://host:port/`, scheme and host are lowercased,
17/// default ports are dropped, duplicates (after canonicalisation) are
18/// removed, and SOCKS schemes are rejected unless the `socks` feature is
19/// enabled. [`pick`](Self::pick) and [`as_slice`](Self::as_slice) return
20/// the canonical form; [`mark_bad`](Self::mark_bad) and
21/// [`in_cooldown`](Self::in_cooldown) accept either form.
22///
23/// All state lives behind an internal mutex that is never held across an
24/// `.await`, so a single `ProxyList` can be used concurrently from many
25/// tasks. Its `Debug` output hides proxy credentials.
26///
27/// # Examples
28///
29/// ```
30/// use reqwest_rotate::ProxyList;
31///
32/// let proxies = ProxyList::new([
33///     "http://proxy-a.example:8080",
34///     "proxy-b.example:8080", // no scheme: treated as http://
35/// ])
36/// .unwrap();
37///
38/// assert_eq!(proxies.pick(), Some("http://proxy-a.example:8080/"));
39/// assert_eq!(proxies.pick(), Some("http://proxy-b.example:8080/"));
40/// assert_eq!(proxies.pick(), Some("http://proxy-a.example:8080/"));
41/// ```
42pub struct ProxyList {
43    proxies: Vec<String>,
44    state: Mutex<State>,
45}
46
47#[derive(Debug)]
48struct State {
49    next_index: usize,
50    /// `bad_until[i]` is the end of proxy `i`'s cooldown, if it is in one.
51    bad_until: Vec<Option<Instant>>,
52}
53
54impl ProxyList {
55    /// Builds a proxy list from proxy URLs such as
56    /// `"http://user:pass@host:port"`. An empty iterator is valid and means
57    /// "no proxies": [`pick`](Self::pick) then always returns `None`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::InvalidProxy`] if an entry is blank, cannot be
62    /// parsed as a URL, or uses an unsupported scheme.
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use reqwest_rotate::ProxyList;
68    ///
69    /// let empty = ProxyList::new(Vec::<String>::new()).unwrap();
70    /// assert!(empty.is_empty());
71    /// assert_eq!(empty.pick(), None);
72    /// ```
73    pub fn new<I, S>(proxies: I) -> Result<Self, Error>
74    where
75        I: IntoIterator<Item = S>,
76        S: AsRef<str>,
77    {
78        let mut normalized: Vec<String> = Vec::new();
79        let mut seen: HashSet<String> = HashSet::new();
80        for proxy in proxies {
81            let url = normalize_proxy_url(proxy.as_ref())?;
82            // Order is the rotation order, so the Vec stays; the set only
83            // answers "have I taken this one already" in O(1) instead of
84            // scanning.
85            if seen.insert(url.clone()) {
86                normalized.push(url);
87            }
88        }
89        let bad_until = vec![None; normalized.len()];
90        Ok(Self {
91            proxies: normalized,
92            state: Mutex::new(State {
93                next_index: 0,
94                bad_until,
95            }),
96        })
97    }
98
99    /// Returns `true` if no proxies were configured.
100    #[must_use]
101    pub fn is_empty(&self) -> bool {
102        self.proxies.is_empty()
103    }
104
105    /// Number of configured (distinct) proxies, regardless of cooldown state.
106    #[must_use]
107    pub fn len(&self) -> usize {
108        self.proxies.len()
109    }
110
111    /// All configured proxy URLs, canonicalised, in rotation order.
112    #[must_use]
113    pub fn as_slice(&self) -> &[String] {
114        &self.proxies
115    }
116
117    /// Index of `proxy`, accepting either the canonical form or anything
118    /// that canonicalises to it.
119    fn position(&self, proxy: &str) -> Option<usize> {
120        self.proxies.iter().position(|p| p == proxy).or_else(|| {
121            let canonical = normalize_proxy_url(proxy).ok()?;
122            self.proxies.iter().position(|p| *p == canonical)
123        })
124    }
125
126    /// The proxy at `idx` with any `user:password@` replaced by `***@`,
127    /// for logs. Only referenced from `trace_log!` call sites, which
128    /// compile away without the `tracing` feature.
129    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
130    pub(crate) fn redacted(&self, idx: usize) -> String {
131        redact_userinfo(&self.proxies[idx])
132    }
133
134    /// Picks the next proxy in round-robin order, skipping proxies that are
135    /// still in cooldown when a healthy one is available. Returns `None`
136    /// only if the list is empty.
137    ///
138    /// If every proxy is in cooldown, the one whose cooldown ends soonest
139    /// is returned anyway: a proxy that might work beats no proxy at all.
140    /// A proxy that then answers a request sent through a
141    /// [`RotatingClient`](crate::RotatingClient) that rotates over this
142    /// list is taken out of cooldown at once; the others stay marked until
143    /// their own cooldown expires.
144    pub fn pick(&self) -> Option<&str> {
145        self.pick_index().map(|idx| self.proxies[idx].as_str())
146    }
147
148    /// Same as [`pick`](Self::pick) but returns the index into
149    /// [`as_slice`](Self::as_slice), so the client can map it straight to a
150    /// pre-built `reqwest::Client` without hashing or cloning the URL.
151    pub(crate) fn pick_index(&self) -> Option<usize> {
152        let len = self.proxies.len();
153        if len == 0 {
154            return None;
155        }
156        let mut state = self.lock();
157        let now = Instant::now();
158        let start = state.next_index;
159
160        // One lap looking for a proxy that is not in cooldown.
161        for offset in 0..len {
162            let idx = (start + offset) % len;
163            let healthy = state.bad_until[idx].is_none_or(|until| now >= until);
164            if healthy {
165                state.bad_until[idx] = None;
166                state.next_index = (idx + 1) % len;
167                return Some(idx);
168            }
169        }
170
171        // Everything is cooling down: take the one that recovers first,
172        // preferring rotation order on ties.
173        let idx = (0..len)
174            .map(|offset| (start + offset) % len)
175            .min_by_key(|&idx| state.bad_until[idx])
176            .expect("len > 0");
177        state.next_index = (idx + 1) % len;
178        Some(idx)
179    }
180
181    /// Whether a proxy other than `idx` is out of cooldown right now, i.e.
182    /// whether the next [`pick`](Self::pick) can avoid the one that just
183    /// failed. `false` when the list holds no other proxy.
184    pub(crate) fn any_healthy_except(&self, idx: usize) -> bool {
185        let state = self.lock();
186        let now = Instant::now();
187        state
188            .bad_until
189            .iter()
190            .enumerate()
191            .any(|(i, until)| i != idx && until.is_none_or(|until| now >= until))
192    }
193
194    /// Marks a proxy as bad for `cooldown`: [`pick`](Self::pick) will skip
195    /// it, unless every proxy is unhealthy, until the cooldown expires or
196    /// the proxy answers a request sent through a
197    /// [`RotatingClient`](crate::RotatingClient), whichever comes first.
198    ///
199    /// `proxy` is matched in canonical form, so both what
200    /// [`pick`](Self::pick) returned and what you originally configured
201    /// work. Returns `false` if it is not in this list, in which case
202    /// nothing changes.
203    pub fn mark_bad(&self, proxy: &str, cooldown: Duration) -> bool {
204        match self.position(proxy) {
205            Some(idx) => {
206                self.mark_bad_index(idx, cooldown);
207                true
208            }
209            None => false,
210        }
211    }
212
213    pub(crate) fn mark_bad_index(&self, idx: usize, cooldown: Duration) {
214        let now = Instant::now();
215        let until = now
216            .checked_add(cooldown)
217            // A cooldown that overflows `Instant` is capped rather than dropped.
218            .or_else(|| now.checked_add(crate::MAX_DURATION))
219            .unwrap_or(now);
220        self.lock().bad_until[idx] = Some(until);
221    }
222
223    /// Takes a proxy out of cooldown: it just answered, so whatever put it
224    /// there is stale. Recovery follows evidence rather than the clock.
225    pub(crate) fn mark_good_index(&self, idx: usize) {
226        self.lock().bad_until[idx] = None;
227    }
228
229    /// Returns `true` if `proxy` (in either form, see
230    /// [`mark_bad`](Self::mark_bad)) is currently in cooldown.
231    pub fn in_cooldown(&self, proxy: &str) -> bool {
232        let Some(idx) = self.position(proxy) else {
233            return false;
234        };
235        let state = self.lock();
236        state.bad_until[idx].is_some_and(|until| Instant::now() < until)
237    }
238
239    /// Locks the state, recovering from a poisoned mutex: the state is a
240    /// few integers that are always left consistent, so a panic elsewhere
241    /// must not take the whole pool down with it.
242    fn lock(&self) -> MutexGuard<'_, State> {
243        self.state.lock().unwrap_or_else(PoisonError::into_inner)
244    }
245}
246
247impl fmt::Debug for ProxyList {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        let redacted: Vec<String> = self.proxies.iter().map(|p| redact_userinfo(p)).collect();
250        let in_cooldown: Vec<&str> = {
251            let state = self.lock();
252            let now = Instant::now();
253            redacted
254                .iter()
255                .zip(&state.bad_until)
256                .filter(|(_, until)| until.is_some_and(|until| now < until))
257                .map(|(url, _)| url.as_str())
258                .collect()
259        };
260        f.debug_struct("ProxyList")
261            .field("proxies", &redacted)
262            .field("in_cooldown", &in_cooldown)
263            .finish()
264    }
265}
266
267/// Replaces `user:password@` in a proxy URL with `***@` so credentials
268/// never end up in logs, error messages, or `Debug` output, whether or
269/// not the URL has a scheme, and even when the password itself contains
270/// `@`.
271pub(crate) fn redact_userinfo(url: &str) -> String {
272    let (scheme, rest) = match url.split_once("://") {
273        Some((scheme, rest)) => (Some(scheme), rest),
274        None => (None, url),
275    };
276    // Userinfo can only live in the authority, which ends at the first
277    // `/`, `?` or `#`; its separator is the *last* `@` there, because an
278    // `@` inside the password is legal input (the url crate escapes it).
279    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
280    let (authority, tail) = rest.split_at(authority_end);
281    let Some((_, host)) = authority.rsplit_once('@') else {
282        return url.to_string();
283    };
284    match scheme {
285        Some(scheme) => format!("{scheme}://***@{host}{tail}"),
286        None => format!("***@{host}{tail}"),
287    }
288}
289
290/// Redacts a proxy spelling that `Url::parse` rejected: the authority's
291/// end is unknown, so everything up to the last `@` is treated as
292/// credentials.
293fn redact_unparsable(raw: &str) -> String {
294    let Some(at) = raw.rfind('@') else {
295        return raw.to_string();
296    };
297    let tail = &raw[at + 1..];
298    let prefix = raw
299        .find("://")
300        .filter(|p| *p < at)
301        .map(|p| &raw[..p + 3])
302        .unwrap_or("");
303    format!("{prefix}***@{tail}")
304}
305
306/// Validates a proxy URL and brings it into the canonical form `reqwest`
307/// expects: lowercase scheme and host, default port dropped, trailing `/`.
308///
309/// Accepts `scheme://[user:pass@]host[:port]` for the supported schemes,
310/// and a bare `[user:pass@]host[:port]`, which gets `http://` prepended.
311fn normalize_proxy_url(raw: &str) -> Result<String, Error> {
312    let raw = raw.trim();
313    if raw.is_empty() {
314        return Err(Error::invalid_proxy(String::new(), "proxy URL is empty"));
315    }
316
317    // An explicit `scheme://` is taken at face value (and its scheme
318    // checked below). Anything else (`host:port`, `1.2.3.4:8080`,
319    // `user:pass@host:port`) is treated as an HTTP proxy. Trying to parse
320    // those directly would misread `host` as a scheme, so don't.
321    let has_scheme = raw.contains("://");
322    let with_scheme = if has_scheme {
323        raw.to_string()
324    } else {
325        format!("http://{raw}")
326    };
327    // Never interpolate `raw` itself into an error message below: it may
328    // carry `user:pass@`, and errors must not surface proxy credentials
329    // any more than Debug output does. Redacting `raw` rather than
330    // `with_scheme` keeps the message in the caller's own spelling. Input
331    // the parser rejects goes through `redact_unparsable` instead, which
332    // assumes the worst about where the credentials end.
333    let shown = redact_userinfo(raw);
334    let url = reqwest::Url::parse(&with_scheme)
335        .ok()
336        .filter(reqwest::Url::has_host)
337        .ok_or_else(|| {
338            let unparsable = redact_unparsable(raw);
339            Error::invalid_proxy(unparsable, "not a valid proxy URL")
340        })?;
341
342    check_scheme(url.scheme(), &shown)?;
343    Ok(url.to_string())
344}
345
346fn check_scheme(scheme: &str, shown: &str) -> Result<(), Error> {
347    match scheme {
348        "http" | "https" => Ok(()),
349        "socks4" | "socks4a" | "socks5" | "socks5h" => {
350            if cfg!(feature = "socks") {
351                Ok(())
352            } else {
353                Err(Error::invalid_proxy(
354                    shown,
355                    "SOCKS proxies need the `socks` feature of reqwest-rotate",
356                ))
357            }
358        }
359        other => Err(Error::invalid_proxy(
360            shown,
361            format!("unsupported proxy scheme `{other}`"),
362        )),
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    fn list(proxies: &[&str]) -> ProxyList {
371        ProxyList::new(proxies).unwrap()
372    }
373
374    #[test]
375    fn round_robin_cycles_through_all_proxies() {
376        let list = list(&["http://a", "http://b", "http://c"]);
377        assert_eq!(list.pick(), Some("http://a/"));
378        assert_eq!(list.pick(), Some("http://b/"));
379        assert_eq!(list.pick(), Some("http://c/"));
380        assert_eq!(list.pick(), Some("http://a/"));
381    }
382
383    #[test]
384    fn empty_list_has_no_pick() {
385        let list = ProxyList::new(Vec::<String>::new()).unwrap();
386        assert!(list.is_empty());
387        assert_eq!(list.len(), 0);
388        assert_eq!(list.pick(), None);
389    }
390
391    #[test]
392    fn rejects_blank_proxy_entries() {
393        let err = ProxyList::new(["  "]).unwrap_err();
394        assert!(matches!(err, Error::InvalidProxy { .. }));
395    }
396
397    #[test]
398    fn rejects_unparseable_and_unknown_schemes() {
399        assert!(matches!(
400            ProxyList::new(["not a valid proxy url"]).unwrap_err(),
401            Error::InvalidProxy { .. }
402        ));
403        assert!(matches!(
404            ProxyList::new(["ftp://proxy.example:21"]).unwrap_err(),
405            Error::InvalidProxy { .. }
406        ));
407    }
408
409    #[test]
410    fn scheme_less_entries_are_treated_as_http() {
411        let list = list(&[
412            "1.2.3.4:8080",
413            "user:pass@proxy.example:3128",
414            "localhost:9",
415        ]);
416        assert_eq!(
417            list.as_slice(),
418            &[
419                "http://1.2.3.4:8080/",
420                "http://user:pass@proxy.example:3128/",
421                "http://localhost:9/",
422            ]
423        );
424    }
425
426    #[test]
427    fn urls_are_canonicalised() {
428        let list = list(&[
429            "HTTP://Proxy.Example:80",
430            "https://user:pass@proxy.example:443",
431            "http://proxy.example:8080/",
432        ]);
433        assert_eq!(
434            list.as_slice(),
435            &[
436                "http://proxy.example/",
437                "https://user:pass@proxy.example/",
438                "http://proxy.example:8080/",
439            ]
440        );
441    }
442
443    #[cfg(not(feature = "socks"))]
444    #[test]
445    fn socks_is_rejected_without_the_feature() {
446        let err = ProxyList::new(["socks5://127.0.0.1:1080"]).unwrap_err();
447        let Error::InvalidProxy { .. } = &err else {
448            panic!("expected InvalidProxy");
449        };
450        assert!(err.to_string().contains("socks"), "{err}");
451    }
452
453    #[cfg(feature = "socks")]
454    #[test]
455    fn socks_is_accepted_with_the_feature() {
456        let list = list(&["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1081"]);
457        assert_eq!(list.len(), 2);
458    }
459
460    #[test]
461    fn duplicates_are_dropped_keeping_first_position() {
462        let list = list(&["http://a", "http://b", "http://a/", "b", "HTTP://A:80"]);
463        assert_eq!(list.as_slice(), &["http://a/", "http://b/"]);
464    }
465
466    #[test]
467    fn dedup_holds_at_scale() {
468        // Every second entry repeats the one before it, so 2000 inputs
469        // collapse to 1000 distinct proxies. This exercises dedup at a size
470        // the other tests never reach; it is a correctness check, not a
471        // benchmark (no timing assertion here).
472        let proxies: Vec<String> = (0..2000)
473            .map(|i| format!("http://10.0.0.{}:{}", i / 2 % 256, 9000 + i / 2))
474            .collect();
475        let list = ProxyList::new(&proxies).unwrap();
476        assert_eq!(list.len(), 1000);
477        assert_eq!(list.as_slice()[0], "http://10.0.0.0:9000/");
478        assert_eq!(list.as_slice()[999], "http://10.0.0.231:9999/");
479    }
480
481    #[test]
482    fn mark_bad_is_skipped_until_cooldown_expires() {
483        let list = list(&["http://a", "http://b"]);
484        assert_eq!(list.pick(), Some("http://a/"));
485        // The un-canonicalised spelling is accepted too.
486        assert!(list.mark_bad("http://b", Duration::from_millis(50)));
487        assert!(list.in_cooldown("http://b/"));
488        assert!(list.in_cooldown("b"));
489        // "b" is next in rotation but is in cooldown, so "a" is served again.
490        assert_eq!(list.pick(), Some("http://a/"));
491        std::thread::sleep(Duration::from_millis(80));
492        assert!(!list.in_cooldown("http://b/"));
493        assert_eq!(list.pick(), Some("http://b/"));
494    }
495
496    #[test]
497    fn mark_bad_on_unknown_proxy_is_a_no_op() {
498        let list = list(&["http://a"]);
499        assert!(!list.mark_bad("http://nope", Duration::from_secs(60)));
500        assert!(!list.mark_bad("not a url at all", Duration::from_secs(60)));
501        assert!(!list.in_cooldown("http://nope"));
502        assert_eq!(list.pick(), Some("http://a/"));
503    }
504
505    #[test]
506    fn mark_good_index_clears_a_cooldown() {
507        let list = list(&["http://a", "http://b"]);
508        list.mark_bad("http://a", Duration::from_secs(60));
509        assert!(list.in_cooldown("http://a"));
510        list.mark_good_index(0);
511        assert!(!list.in_cooldown("http://a"));
512        assert_eq!(list.pick(), Some("http://a/"));
513    }
514
515    #[test]
516    fn all_proxies_bad_returns_the_one_recovering_first() {
517        let list = list(&["http://a", "http://b", "http://c"]);
518        list.mark_bad("http://a", Duration::from_secs(60));
519        list.mark_bad("http://b", Duration::from_secs(10));
520        list.mark_bad("http://c", Duration::from_secs(60));
521        assert_eq!(list.pick(), Some("http://b/"));
522        // With every proxy cooling down, consecutive picks repeat the
523        // soonest-recovering one: nothing here has answered to clear it.
524        assert_eq!(list.pick(), Some("http://b/"));
525    }
526
527    #[test]
528    fn any_healthy_except_ignores_the_given_index() {
529        let pool = list(&["http://a", "http://b"]);
530        assert!(pool.any_healthy_except(0));
531        pool.mark_bad("http://b", Duration::from_secs(60));
532        assert!(!pool.any_healthy_except(0));
533        assert!(pool.any_healthy_except(1));
534
535        // Once a cooldown has actually expired (not just been set to a
536        // duration too short to ever matter), the proxy it covers counts as
537        // healthy again: "a" recovers while "b" (excluded above) stays bad.
538        pool.mark_bad("http://a", Duration::from_millis(50));
539        std::thread::sleep(Duration::from_millis(80));
540        assert!(pool.any_healthy_except(1));
541
542        let single = list(&["http://a"]);
543        assert!(!single.any_healthy_except(0));
544    }
545
546    #[test]
547    fn huge_cooldown_does_not_panic_or_poison() {
548        let list = list(&["http://a", "http://b"]);
549        list.mark_bad("http://a", Duration::MAX);
550        assert!(list.in_cooldown("http://a"));
551        assert_eq!(list.pick(), Some("http://b/"));
552    }
553
554    #[test]
555    fn debug_output_hides_credentials() {
556        let list = list(&["http://user:s3cret@a:8080", "http://b"]);
557        list.mark_bad("http://b", Duration::from_secs(60));
558        let debug = format!("{list:?}");
559        assert!(!debug.contains("s3cret"), "{debug}");
560        assert!(debug.contains("http://***@a:8080/"), "{debug}");
561        assert!(debug.contains("in_cooldown: [\"http://b/\"]"), "{debug}");
562    }
563
564    #[test]
565    fn redaction_handles_urls_without_credentials() {
566        assert_eq!(redact_userinfo("http://a:8080/"), "http://a:8080/");
567        assert_eq!(redact_userinfo("http://u:p@a/"), "http://***@a/");
568        assert_eq!(redact_userinfo("socks5://u@a/"), "socks5://***@a/");
569        assert_eq!(redact_userinfo("garbage"), "garbage");
570        assert_eq!(
571            redact_userinfo("user:pass@proxy.example:3128"),
572            "***@proxy.example:3128"
573        );
574        assert_eq!(
575            redact_userinfo("http://user:p@ss@proxy.example:3128"),
576            "http://***@proxy.example:3128"
577        );
578        assert_eq!(redact_userinfo("http://h/@path"), "http://h/@path");
579        assert_eq!(
580            redact_userinfo("http://u:p@h/@path?x=@y"),
581            "http://***@h/@path?x=@y"
582        );
583    }
584
585    #[test]
586    fn unparsable_redaction_assumes_the_worst() {
587        assert_eq!(
588            redact_unparsable("http://user:p?ss@host:3128"),
589            "http://***@host:3128"
590        );
591        assert_eq!(redact_unparsable("user:p#ss@host:3128"), "***@host:3128");
592        assert_eq!(redact_unparsable("a@b@c"), "***@c");
593        assert_eq!(redact_unparsable("nope"), "nope");
594        assert_eq!(redact_unparsable("http://"), "http://");
595        assert_eq!(redact_unparsable("user@host://x"), "***@host://x");
596    }
597
598    #[test]
599    fn invalid_proxy_errors_redact_credentials() {
600        let message = ProxyList::new(["ftp://user:pass@host:21"])
601            .unwrap_err()
602            .to_string();
603        assert!(!message.contains("pass"), "{message}");
604        assert!(!message.contains("user:"), "{message}");
605        assert!(message.contains("***@"), "{message}");
606
607        // A schemeless input must not have the `http://` this function
608        // synthesizes internally leak into the message: the caller never
609        // typed it, so the message should open with their own spelling.
610        let schemeless = ProxyList::new(["not a valid proxy url"])
611            .unwrap_err()
612            .to_string();
613        assert!(
614            schemeless.starts_with("invalid proxy: not a valid proxy url:"),
615            "{schemeless}"
616        );
617
618        let schemeless_with_credentials = ProxyList::new(["user:s3cret@not a url"])
619            .unwrap_err()
620            .to_string();
621        assert!(
622            !schemeless_with_credentials.contains("s3cret"),
623            "{schemeless_with_credentials}"
624        );
625        assert!(
626            schemeless_with_credentials.starts_with("invalid proxy: ***@"),
627            "{schemeless_with_credentials}"
628        );
629
630        let scheme_with_at_in_password = ProxyList::new(["ftp://user:p@ss@host:21"])
631            .unwrap_err()
632            .to_string();
633        assert!(
634            !scheme_with_at_in_password.contains("ss@"),
635            "{scheme_with_at_in_password}"
636        );
637        assert!(
638            scheme_with_at_in_password.contains("***@host"),
639            "{scheme_with_at_in_password}"
640        );
641
642        let schemeless_with_at_in_password = ProxyList::new(["user:p@ss@host:99999"])
643            .unwrap_err()
644            .to_string();
645        assert!(
646            !schemeless_with_at_in_password.contains("ss@"),
647            "{schemeless_with_at_in_password}"
648        );
649        assert!(
650            schemeless_with_at_in_password.contains("***@host"),
651            "{schemeless_with_at_in_password}"
652        );
653
654        // A password containing `/`, `?` or `#` makes the URL fail to
655        // parse; the authority boundary is then unknown, so the whole
656        // spelling up to the last `@` must be redacted, not just the part
657        // `redact_userinfo` would have guessed at.
658        let slash_in_password = ProxyList::new(["http://user:p?ss@host:3128"])
659            .unwrap_err()
660            .to_string();
661        assert!(!slash_in_password.contains("p?ss"), "{slash_in_password}");
662        assert_eq!(
663            slash_in_password,
664            "invalid proxy: http://***@host:3128: not a valid proxy URL"
665        );
666
667        let hash_in_password = ProxyList::new(["user:p#ss@host:3128"])
668            .unwrap_err()
669            .to_string();
670        assert!(!hash_in_password.contains("p#ss"), "{hash_in_password}");
671        assert!(hash_in_password.contains("***@host:3128: not a valid proxy URL"));
672
673        // Guard: input with no `@` at all is unaffected by the redaction
674        // change, before or after.
675        let no_credentials = ProxyList::new(["http://"]).unwrap_err().to_string();
676        assert_eq!(
677            no_credentials,
678            "invalid proxy: http://: not a valid proxy URL"
679        );
680    }
681}