Skip to main content

scatter_proxy/
proxy.rs

1use dashmap::DashMap;
2
3use std::sync::atomic::{AtomicU32, Ordering};
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::error::ScatterProxyError;
8
9/// State of a proxy in the manager.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ProxyState {
12    Unknown,
13    Active,
14    /// Proxy was eliminated due to sustained failure.  Not automatically
15    /// recovered — requires a restart or explicit state reset.
16    Dead,
17    /// Proxy reached its per-node request limit and was voluntarily retired.
18    /// Unlike `Dead`, `Retired` nodes can be reactivated on source refresh.
19    Retired,
20}
21
22/// Internal info stored per proxy.
23struct ProxyInfo {
24    state: ProxyState,
25}
26
27/// Manages a set of SOCKS5 proxies: fetching from sources, normalizing URLs,
28/// tracking state, and caching `reqwest::Client` instances.
29pub struct ProxyManager {
30    proxies: DashMap<String, ProxyInfo>,
31    clients: DashMap<String, reqwest::Client>,
32    proxy_timeout: Duration,
33    /// Per-proxy WAF cookie values (`proxy_url` → cookie value).
34    cookies: DashMap<String, String>,
35    /// Per-proxy cumulative request counters.
36    request_counts: DashMap<String, Arc<AtomicU32>>,
37    /// Per-proxy semaphore ensuring at most one concurrent challenge solve.
38    challenge_locks: DashMap<String, Arc<tokio::sync::Semaphore>>,
39}
40
41impl ProxyManager {
42    /// Create a new `ProxyManager` with the given per-proxy connection timeout.
43    pub fn new(proxy_timeout: Duration) -> Self {
44        let proxies = DashMap::new();
45        Self {
46            proxies,
47            clients: DashMap::new(),
48            proxy_timeout,
49            cookies: DashMap::new(),
50            request_counts: DashMap::new(),
51            challenge_locks: DashMap::new(),
52        }
53    }
54
55    // ── Per-proxy cookie management ──────────────────────────────────────────
56
57    /// Retrieve the stored cookie value for a proxy node, if any.
58    pub fn get_cookie(&self, proxy_url: &str) -> Option<String> {
59        self.cookies.get(proxy_url).map(|v| v.clone())
60    }
61
62    /// Store a cookie value for a proxy node, replacing any existing value.
63    pub fn set_cookie(&self, proxy_url: &str, value: String) {
64        self.cookies.insert(proxy_url.to_string(), value);
65    }
66
67    // ── Per-proxy request counting ───────────────────────────────────────────
68
69    /// Atomically increment the request count for a proxy and return the new value.
70    pub fn increment_request_count(&self, proxy_url: &str) -> u32 {
71        self.request_counts
72            .entry(proxy_url.to_string())
73            .or_insert_with(|| Arc::new(AtomicU32::new(0)))
74            .fetch_add(1, Ordering::Relaxed)
75            + 1
76    }
77
78    /// Return the current request count for a proxy node.
79    pub fn request_count(&self, proxy_url: &str) -> u32 {
80        self.request_counts
81            .get(proxy_url)
82            .map(|c| c.load(Ordering::Relaxed))
83            .unwrap_or(0)
84    }
85
86    /// Reset the request count for a proxy node and, if the node was `Retired`,
87    /// restore it to `Active`.  `Dead` nodes are not restored.
88    pub fn reset_request_count(&self, proxy_url: &str) {
89        if let Some(counter) = self.request_counts.get(proxy_url) {
90            counter.store(0, Ordering::Relaxed);
91        }
92        if matches!(self.get_state(proxy_url), ProxyState::Retired) {
93            self.set_state(proxy_url, ProxyState::Active);
94        }
95    }
96
97    // ── Per-proxy challenge serialization ────────────────────────────────────
98
99    /// Acquire the per-proxy challenge lock (semaphore with 1 permit).
100    ///
101    /// Ensures at most one concurrent challenge solve per proxy node.
102    pub fn challenge_lock(&self, proxy_url: &str) -> Arc<tokio::sync::Semaphore> {
103        self.challenge_locks
104            .entry(proxy_url.to_string())
105            .or_insert_with(|| Arc::new(tokio::sync::Semaphore::new(1)))
106            .clone()
107    }
108
109    /// Fetch proxy lists from the given source URLs, normalize each line, and
110    /// add any new proxies to the manager.
111    ///
112    /// Returns the count of newly added proxies across all sources.
113    /// Errors fetching individual sources are logged but do not cause the
114    /// overall call to fail.
115    pub async fn fetch_and_add(
116        &self,
117        sources: &[String],
118        prefer_remote_dns: bool,
119    ) -> Result<usize, ScatterProxyError> {
120        let http_client = reqwest::Client::builder()
121            .timeout(Duration::from_secs(30))
122            .build()
123            .map_err(|e| ScatterProxyError::Init(format!("failed to build HTTP client: {e}")))?;
124
125        let mut added = 0usize;
126
127        for source in sources {
128            match http_client.get(source).send().await {
129                Ok(resp) => match resp.text().await {
130                    Ok(body) => {
131                        for line in body.lines() {
132                            let normalized = Self::normalize_proxy_url(line, prefer_remote_dns);
133                            if normalized.is_empty() {
134                                continue;
135                            }
136                            if !self.proxies.contains_key(&normalized) {
137                                self.proxies.insert(
138                                    normalized,
139                                    ProxyInfo {
140                                        state: ProxyState::Unknown,
141                                    },
142                                );
143                                added += 1;
144                            }
145                        }
146                    }
147                    Err(e) => {
148                        tracing::error!("failed to read body from source {source}: {e}");
149                    }
150                },
151                Err(e) => {
152                    tracing::error!("failed to fetch proxy source {source}: {e}");
153                }
154            }
155        }
156
157        Ok(added)
158    }
159
160    /// Get all proxy URLs currently in the manager.
161    pub fn all_proxy_urls(&self) -> Vec<String> {
162        self.proxies.iter().map(|r| r.key().clone()).collect()
163    }
164
165    /// Get proxy URLs that are eligible for scheduling (not `Dead` or `Retired`).
166    pub fn get_active_proxies(&self) -> Vec<String> {
167        self.proxies
168            .iter()
169            .filter(|r| !matches!(r.value().state, ProxyState::Dead | ProxyState::Retired))
170            .map(|r| r.key().clone())
171            .collect()
172    }
173
174    /// Set the state of a proxy.
175    pub fn set_state(&self, proxy: &str, state: ProxyState) {
176        if let Some(mut entry) = self.proxies.get_mut(proxy) {
177            entry.state = state;
178        }
179    }
180
181    /// Get the state of a proxy. Returns `Unknown` if the proxy is not tracked.
182    pub fn get_state(&self, proxy: &str) -> ProxyState {
183        self.proxies
184            .get(proxy)
185            .map(|r| r.value().state)
186            .unwrap_or(ProxyState::Unknown)
187    }
188
189    /// Count proxies by state.
190    ///
191    /// Returns `(total, active_or_unknown, cooldown_placeholder, dead, retired)`.
192    /// The cooldown placeholder is always 0 — cooldown tracking lives in a separate module.
193    pub fn proxy_counts(&self) -> (usize, usize, usize, usize, usize) {
194        let mut total = 0usize;
195        let mut active_or_unknown = 0usize;
196        let mut dead = 0usize;
197        let mut retired = 0usize;
198
199        for entry in self.proxies.iter() {
200            total += 1;
201            match entry.value().state {
202                ProxyState::Dead => dead += 1,
203                ProxyState::Retired => retired += 1,
204                _ => active_or_unknown += 1,
205            }
206        }
207
208        (total, active_or_unknown, 0, dead, retired)
209    }
210
211    /// Get or create a `reqwest::Client` configured to route through the given
212    /// proxy URL.  Clients are cached for reuse.
213    pub fn get_client(&self, proxy_url: &str) -> Result<reqwest::Client, ScatterProxyError> {
214        if let Some(client) = self.clients.get(proxy_url) {
215            return Ok(client.value().clone());
216        }
217
218        let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| {
219            ScatterProxyError::Init(format!("invalid proxy URL '{proxy_url}': {e}"))
220        })?;
221
222        let client = reqwest::Client::builder()
223            .proxy(proxy)
224            .timeout(self.proxy_timeout)
225            .build()
226            .map_err(|e| {
227                ScatterProxyError::Init(format!(
228                    "failed to build client for proxy '{proxy_url}': {e}"
229                ))
230            })?;
231
232        self.clients.insert(proxy_url.to_string(), client.clone());
233        Ok(client)
234    }
235
236    /// Normalize a raw proxy string to a full URL.
237    ///
238    /// Rules:
239    /// - Empty lines and lines starting with `#` are returned as empty string.
240    /// - `"socks5h://ip:port"` → returned as-is.
241    /// - `"socks5://ip:port"` → replaced with `"socks5h://"` when
242    ///   `prefer_remote_dns` is true, otherwise kept as-is.
243    /// - Bare `"ip:port"` → prepended with `"socks5h://"` when
244    ///   `prefer_remote_dns` is true, otherwise `"socks5://"`.
245    pub fn normalize_proxy_url(raw: &str, prefer_remote_dns: bool) -> String {
246        let trimmed = raw.trim();
247
248        if trimmed.is_empty() || trimmed.starts_with('#') {
249            return String::new();
250        }
251
252        if trimmed.starts_with("socks5h://") {
253            return trimmed.to_string();
254        }
255
256        if let Some(rest) = trimmed.strip_prefix("socks5://") {
257            if prefer_remote_dns {
258                return format!("socks5h://{rest}");
259            }
260            return trimmed.to_string();
261        }
262
263        // Also handle http/https proxies as-is
264        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
265            return trimmed.to_string();
266        }
267
268        // Bare ip:port
269        if prefer_remote_dns {
270            format!("socks5h://{trimmed}")
271        } else {
272            format!("socks5://{trimmed}")
273        }
274    }
275
276    /// Total number of proxies being tracked.
277    #[allow(dead_code)]
278    pub fn total_count(&self) -> usize {
279        self.proxies.len()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    // -----------------------------------------------------------------------
288    // normalize_proxy_url
289    // -----------------------------------------------------------------------
290
291    #[test]
292    fn normalize_bare_ip_port_prefer_remote_dns() {
293        let result = ProxyManager::normalize_proxy_url("1.2.3.4:1080", true);
294        assert_eq!(result, "socks5h://1.2.3.4:1080");
295    }
296
297    #[test]
298    fn normalize_bare_ip_port_local_dns() {
299        let result = ProxyManager::normalize_proxy_url("1.2.3.4:1080", false);
300        assert_eq!(result, "socks5://1.2.3.4:1080");
301    }
302
303    #[test]
304    fn normalize_socks5_to_socks5h_when_prefer_remote() {
305        let result = ProxyManager::normalize_proxy_url("socks5://1.2.3.4:1080", true);
306        assert_eq!(result, "socks5h://1.2.3.4:1080");
307    }
308
309    #[test]
310    fn normalize_socks5_stays_when_not_prefer_remote() {
311        let result = ProxyManager::normalize_proxy_url("socks5://1.2.3.4:1080", false);
312        assert_eq!(result, "socks5://1.2.3.4:1080");
313    }
314
315    #[test]
316    fn normalize_socks5h_unchanged() {
317        let result = ProxyManager::normalize_proxy_url("socks5h://1.2.3.4:1080", true);
318        assert_eq!(result, "socks5h://1.2.3.4:1080");
319    }
320
321    #[test]
322    fn normalize_socks5h_unchanged_no_prefer() {
323        let result = ProxyManager::normalize_proxy_url("socks5h://1.2.3.4:1080", false);
324        assert_eq!(result, "socks5h://1.2.3.4:1080");
325    }
326
327    #[test]
328    fn normalize_empty_string() {
329        let result = ProxyManager::normalize_proxy_url("", true);
330        assert!(result.is_empty());
331    }
332
333    #[test]
334    fn normalize_whitespace_only() {
335        let result = ProxyManager::normalize_proxy_url("   ", true);
336        assert!(result.is_empty());
337    }
338
339    #[test]
340    fn normalize_comment_line() {
341        let result = ProxyManager::normalize_proxy_url("# this is a comment", true);
342        assert!(result.is_empty());
343    }
344
345    #[test]
346    fn normalize_trims_whitespace() {
347        let result = ProxyManager::normalize_proxy_url("  1.2.3.4:1080  ", true);
348        assert_eq!(result, "socks5h://1.2.3.4:1080");
349    }
350
351    #[test]
352    fn normalize_http_proxy_passed_through() {
353        let result = ProxyManager::normalize_proxy_url("http://proxy.example.com:8080", true);
354        assert_eq!(result, "http://proxy.example.com:8080");
355    }
356
357    #[test]
358    fn normalize_https_proxy_passed_through() {
359        let result = ProxyManager::normalize_proxy_url("https://proxy.example.com:8080", false);
360        assert_eq!(result, "https://proxy.example.com:8080");
361    }
362
363    #[test]
364    fn normalize_socks5_with_auth() {
365        let result = ProxyManager::normalize_proxy_url("socks5://user:pass@1.2.3.4:1080", true);
366        assert_eq!(result, "socks5h://user:pass@1.2.3.4:1080");
367    }
368
369    // -----------------------------------------------------------------------
370    // ProxyManager::new
371    // -----------------------------------------------------------------------
372
373    // -----------------------------------------------------------------------
374    // State management
375    // -----------------------------------------------------------------------
376
377    #[test]
378    fn get_state_returns_unknown_for_missing_proxy() {
379        let mgr = ProxyManager::new(Duration::from_secs(5));
380        assert_eq!(mgr.get_state("socks5h://9.9.9.9:1080"), ProxyState::Unknown);
381    }
382
383    #[test]
384    fn set_and_get_state() {
385        let pm = ProxyManager::new(Duration::from_secs(8));
386        pm.proxies.insert(
387            "socks5h://1.2.3.4:1080".to_string(),
388            ProxyInfo {
389                state: ProxyState::Unknown,
390            },
391        );
392
393        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Unknown);
394
395        pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Active);
396        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Active);
397
398        pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Dead);
399        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Dead);
400    }
401
402    #[test]
403    fn set_state_on_missing_proxy_is_noop() {
404        let pm = ProxyManager::new(Duration::from_secs(8));
405        pm.set_state("socks5h://nonexistent:1080", ProxyState::Dead);
406        assert_eq!(pm.total_count(), 0);
407    }
408
409    // -----------------------------------------------------------------------
410    // get_active_proxies
411    // -----------------------------------------------------------------------
412
413    #[test]
414    fn get_active_proxies_excludes_dead() {
415        let mgr = ProxyManager::new(Duration::from_secs(5));
416
417        // Manually insert some proxies.
418        mgr.proxies.insert(
419            "socks5h://1.1.1.1:1080".into(),
420            ProxyInfo {
421                state: ProxyState::Active,
422            },
423        );
424        mgr.proxies.insert(
425            "socks5h://2.2.2.2:1080".into(),
426            ProxyInfo {
427                state: ProxyState::Dead,
428            },
429        );
430        mgr.proxies.insert(
431            "socks5h://3.3.3.3:1080".into(),
432            ProxyInfo {
433                state: ProxyState::Unknown,
434            },
435        );
436
437        let active = mgr.get_active_proxies();
438        assert_eq!(active.len(), 2);
439        assert!(active.contains(&"socks5h://1.1.1.1:1080".to_string()));
440        assert!(!active.contains(&"socks5h://2.2.2.2:1080".to_string()));
441        assert!(active.contains(&"socks5h://3.3.3.3:1080".to_string()));
442    }
443
444    // -----------------------------------------------------------------------
445    // proxy_counts
446    // -----------------------------------------------------------------------
447
448    #[test]
449    fn proxy_counts_correct() {
450        let pm = ProxyManager::new(Duration::from_secs(8));
451
452        pm.proxies.insert(
453            "a".to_string(),
454            ProxyInfo {
455                state: ProxyState::Active,
456            },
457        );
458        pm.proxies.insert(
459            "b".to_string(),
460            ProxyInfo {
461                state: ProxyState::Unknown,
462            },
463        );
464        pm.proxies.insert(
465            "c".to_string(),
466            ProxyInfo {
467                state: ProxyState::Dead,
468            },
469        );
470        pm.proxies.insert(
471            "d".to_string(),
472            ProxyInfo {
473                state: ProxyState::Dead,
474            },
475        );
476
477        let (total, active_or_unknown, cooldown, dead, retired) = pm.proxy_counts();
478        assert_eq!(total, 4);
479        assert_eq!(active_or_unknown, 2);
480        assert_eq!(cooldown, 0);
481        assert_eq!(dead, 2);
482        assert_eq!(retired, 0);
483    }
484
485    #[test]
486    fn proxy_counts_empty() {
487        let pm = ProxyManager::new(Duration::from_secs(8));
488        let (total, active, cooldown, dead, retired) = pm.proxy_counts();
489        assert_eq!(total, 0);
490        assert_eq!(active, 0);
491        assert_eq!(cooldown, 0);
492        assert_eq!(dead, 0);
493        assert_eq!(retired, 0);
494    }
495
496    // -----------------------------------------------------------------------
497    // get_client
498    // -----------------------------------------------------------------------
499
500    #[test]
501    fn get_client_creates_and_caches() {
502        let pm = ProxyManager::new(Duration::from_secs(8));
503        let client1 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
504        let client2 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
505
506        // Both should be the same cached client (we can't compare reqwest::Client
507        // directly, but we can verify the cache contains one entry).
508        assert_eq!(pm.clients.len(), 1);
509
510        // Ensure we actually got clients back.
511        drop(client1);
512        drop(client2);
513    }
514
515    #[test]
516    fn get_client_different_proxies_get_different_clients() {
517        let pm = ProxyManager::new(Duration::from_secs(8));
518        let _c1 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
519        let _c2 = pm.get_client("socks5h://127.0.0.1:1081").unwrap();
520        assert_eq!(pm.clients.len(), 2);
521    }
522
523    #[test]
524    fn get_client_invalid_url_returns_error() {
525        let pm = ProxyManager::new(Duration::from_secs(8));
526        let result = pm.get_client("not a valid url at all");
527        // reqwest may or may not reject this; if it does, we expect an Init error.
528        // If reqwest is lenient, the test still passes.
529        if let Err(e) = result {
530            match e {
531                ScatterProxyError::Init(msg) => {
532                    assert!(msg.contains("proxy") || msg.contains("invalid"));
533                }
534                other => panic!("expected Init error, got: {other:?}"),
535            }
536        }
537    }
538
539    // -----------------------------------------------------------------------
540    // all_proxy_urls
541    // -----------------------------------------------------------------------
542
543    #[test]
544    fn all_proxy_urls_returns_all() {
545        let pm = ProxyManager::new(Duration::from_secs(8));
546        pm.proxies.insert(
547            "a".to_string(),
548            ProxyInfo {
549                state: ProxyState::Active,
550            },
551        );
552        pm.proxies.insert(
553            "b".to_string(),
554            ProxyInfo {
555                state: ProxyState::Dead,
556            },
557        );
558
559        let urls = pm.all_proxy_urls();
560        assert_eq!(urls.len(), 2);
561        assert!(urls.contains(&"a".to_string()));
562        assert!(urls.contains(&"b".to_string()));
563    }
564
565    // -----------------------------------------------------------------------
566    // total_count
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn total_count_tracks_insertions() {
571        let mgr = ProxyManager::new(Duration::from_secs(5));
572        assert_eq!(mgr.total_count(), 0);
573
574        mgr.proxies.insert(
575            "socks5h://1.1.1.1:1080".into(),
576            ProxyInfo {
577                state: ProxyState::Active,
578            },
579        );
580        assert_eq!(mgr.total_count(), 1);
581
582        mgr.proxies.insert(
583            "socks5h://2.2.2.2:1080".into(),
584            ProxyInfo {
585                state: ProxyState::Dead,
586            },
587        );
588        assert_eq!(mgr.total_count(), 2);
589    }
590
591    // -----------------------------------------------------------------------
592    // ProxyState enum
593    // -----------------------------------------------------------------------
594
595    #[test]
596    fn proxy_state_debug() {
597        assert_eq!(format!("{:?}", ProxyState::Unknown), "Unknown");
598        assert_eq!(format!("{:?}", ProxyState::Active), "Active");
599        assert_eq!(format!("{:?}", ProxyState::Dead), "Dead");
600        assert_eq!(format!("{:?}", ProxyState::Retired), "Retired");
601    }
602
603    #[test]
604    fn proxy_state_clone_and_eq() {
605        let s = ProxyState::Active;
606        let s2 = s;
607        assert_eq!(s, s2);
608    }
609
610    // -----------------------------------------------------------------------
611    // ProxyState::Retired
612    // -----------------------------------------------------------------------
613
614    #[test]
615    fn set_state_to_retired() {
616        let pm = ProxyManager::new(Duration::from_secs(8));
617        pm.proxies.insert(
618            "socks5h://1.2.3.4:1080".to_string(),
619            ProxyInfo {
620                state: ProxyState::Active,
621            },
622        );
623        pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Retired);
624        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Retired);
625    }
626
627    #[test]
628    fn get_active_proxies_excludes_retired() {
629        let pm = ProxyManager::new(Duration::from_secs(8));
630        pm.proxies.insert(
631            "a".to_string(),
632            ProxyInfo {
633                state: ProxyState::Active,
634            },
635        );
636        pm.proxies.insert(
637            "b".to_string(),
638            ProxyInfo {
639                state: ProxyState::Retired,
640            },
641        );
642        pm.proxies.insert(
643            "c".to_string(),
644            ProxyInfo {
645                state: ProxyState::Unknown,
646            },
647        );
648        let active = pm.get_active_proxies();
649        assert_eq!(active.len(), 2);
650        assert!(active.contains(&"a".to_string()));
651        assert!(!active.contains(&"b".to_string()));
652        assert!(active.contains(&"c".to_string()));
653    }
654
655    #[test]
656    fn proxy_counts_includes_retired() {
657        let pm = ProxyManager::new(Duration::from_secs(8));
658        pm.proxies.insert(
659            "a".to_string(),
660            ProxyInfo {
661                state: ProxyState::Active,
662            },
663        );
664        pm.proxies.insert(
665            "b".to_string(),
666            ProxyInfo {
667                state: ProxyState::Retired,
668            },
669        );
670        pm.proxies.insert(
671            "c".to_string(),
672            ProxyInfo {
673                state: ProxyState::Dead,
674            },
675        );
676
677        let (total, active, cooldown, dead, retired) = pm.proxy_counts();
678        assert_eq!(total, 3);
679        assert_eq!(active, 1);
680        assert_eq!(cooldown, 0);
681        assert_eq!(dead, 1);
682        assert_eq!(retired, 1);
683    }
684
685    // -----------------------------------------------------------------------
686    // Per-proxy cookie management
687    // -----------------------------------------------------------------------
688
689    #[test]
690    fn get_cookie_returns_none_when_not_set() {
691        let pm = ProxyManager::new(Duration::from_secs(8));
692        assert!(pm.get_cookie("socks5h://1.2.3.4:1080").is_none());
693    }
694
695    #[test]
696    fn set_and_get_cookie() {
697        let pm = ProxyManager::new(Duration::from_secs(8));
698        pm.set_cookie("socks5h://1.2.3.4:1080", "abc123".to_string());
699        assert_eq!(
700            pm.get_cookie("socks5h://1.2.3.4:1080"),
701            Some("abc123".to_string())
702        );
703    }
704
705    #[test]
706    fn set_cookie_overwrites_existing() {
707        let pm = ProxyManager::new(Duration::from_secs(8));
708        pm.set_cookie("socks5h://1.2.3.4:1080", "first".to_string());
709        pm.set_cookie("socks5h://1.2.3.4:1080", "second".to_string());
710        assert_eq!(
711            pm.get_cookie("socks5h://1.2.3.4:1080"),
712            Some("second".to_string())
713        );
714    }
715
716    #[test]
717    fn cookies_are_per_proxy() {
718        let pm = ProxyManager::new(Duration::from_secs(8));
719        pm.set_cookie("socks5h://1.1.1.1:1080", "cookie_a".to_string());
720        pm.set_cookie("socks5h://2.2.2.2:1080", "cookie_b".to_string());
721        assert_eq!(
722            pm.get_cookie("socks5h://1.1.1.1:1080"),
723            Some("cookie_a".to_string())
724        );
725        assert_eq!(
726            pm.get_cookie("socks5h://2.2.2.2:1080"),
727            Some("cookie_b".to_string())
728        );
729    }
730
731    // -----------------------------------------------------------------------
732    // Per-proxy request counting
733    // -----------------------------------------------------------------------
734
735    #[test]
736    fn request_count_starts_at_zero() {
737        let pm = ProxyManager::new(Duration::from_secs(8));
738        assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
739    }
740
741    #[test]
742    fn increment_request_count_increments_correctly() {
743        let pm = ProxyManager::new(Duration::from_secs(8));
744        assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 1);
745        assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 2);
746        assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 3);
747        assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 3);
748    }
749
750    #[test]
751    fn request_counts_are_per_proxy() {
752        let pm = ProxyManager::new(Duration::from_secs(8));
753        pm.increment_request_count("socks5h://1.1.1.1:1080");
754        pm.increment_request_count("socks5h://1.1.1.1:1080");
755        pm.increment_request_count("socks5h://2.2.2.2:1080");
756        assert_eq!(pm.request_count("socks5h://1.1.1.1:1080"), 2);
757        assert_eq!(pm.request_count("socks5h://2.2.2.2:1080"), 1);
758    }
759
760    #[test]
761    fn reset_request_count_clears_count() {
762        let pm = ProxyManager::new(Duration::from_secs(8));
763        pm.increment_request_count("socks5h://1.2.3.4:1080");
764        pm.increment_request_count("socks5h://1.2.3.4:1080");
765        pm.reset_request_count("socks5h://1.2.3.4:1080");
766        assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
767    }
768
769    #[test]
770    fn reset_request_count_restores_retired_to_active() {
771        let pm = ProxyManager::new(Duration::from_secs(8));
772        pm.proxies.insert(
773            "socks5h://1.2.3.4:1080".to_string(),
774            ProxyInfo {
775                state: ProxyState::Retired,
776            },
777        );
778        pm.increment_request_count("socks5h://1.2.3.4:1080");
779        pm.reset_request_count("socks5h://1.2.3.4:1080");
780        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Active);
781        assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
782    }
783
784    #[test]
785    fn reset_request_count_does_not_restore_dead() {
786        let pm = ProxyManager::new(Duration::from_secs(8));
787        pm.proxies.insert(
788            "socks5h://1.2.3.4:1080".to_string(),
789            ProxyInfo {
790                state: ProxyState::Dead,
791            },
792        );
793        pm.reset_request_count("socks5h://1.2.3.4:1080");
794        assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Dead);
795    }
796
797    // -----------------------------------------------------------------------
798    // Challenge lock
799    // -----------------------------------------------------------------------
800
801    #[test]
802    fn challenge_lock_returns_semaphore_with_one_permit() {
803        let pm = ProxyManager::new(Duration::from_secs(8));
804        let sem = pm.challenge_lock("socks5h://1.2.3.4:1080");
805        assert_eq!(sem.available_permits(), 1);
806    }
807
808    #[test]
809    fn challenge_lock_same_proxy_returns_same_semaphore() {
810        let pm = ProxyManager::new(Duration::from_secs(8));
811        let sem1 = pm.challenge_lock("socks5h://1.2.3.4:1080");
812        let sem2 = pm.challenge_lock("socks5h://1.2.3.4:1080");
813        // Both Arc references point to the same semaphore instance.
814        assert!(std::ptr::eq(sem1.as_ref(), sem2.as_ref()));
815    }
816
817    // -----------------------------------------------------------------------
818    // fetch_and_add (unit tests with no real HTTP server)
819    // -----------------------------------------------------------------------
820
821    #[tokio::test]
822    async fn fetch_and_add_with_empty_sources() {
823        let mgr = ProxyManager::new(Duration::from_secs(5));
824        let added = mgr.fetch_and_add(&[], true).await.unwrap();
825        assert_eq!(added, 0);
826        assert_eq!(mgr.total_count(), 0);
827    }
828
829    #[tokio::test]
830    async fn fetch_and_add_with_unreachable_source_logs_error() {
831        let pm = ProxyManager::new(Duration::from_secs(8));
832        // This source won't be reachable but should not cause a hard failure.
833        let added = pm
834            .fetch_and_add(
835                &["http://127.0.0.1:1/nonexistent-proxy-list".to_string()],
836                true,
837            )
838            .await
839            .unwrap();
840        assert_eq!(added, 0);
841        assert_eq!(pm.total_count(), 0);
842    }
843
844    // -----------------------------------------------------------------------
845    // normalize edge cases
846    // -----------------------------------------------------------------------
847
848    #[test]
849    fn normalize_comment_with_leading_whitespace() {
850        // After trimming, starts with '#'
851        let result = ProxyManager::normalize_proxy_url("  # comment", true);
852        assert!(result.is_empty());
853    }
854
855    #[test]
856    fn normalize_ipv6_bare() {
857        let result = ProxyManager::normalize_proxy_url("[::1]:1080", true);
858        assert_eq!(result, "socks5h://[::1]:1080");
859    }
860
861    #[test]
862    fn normalize_socks5_uppercase_not_matched_becomes_bare() {
863        // "SOCKS5://..." doesn't match the lowercase prefix, so it's treated as bare.
864        let result = ProxyManager::normalize_proxy_url("SOCKS5://1.2.3.4:1080", true);
865        assert_eq!(result, "socks5h://SOCKS5://1.2.3.4:1080");
866    }
867}