Skip to main content

stygian_proxy/
fetcher.rs

1//! Proxy list fetching — port trait and free-list HTTP adapter.
2//!
3//! [`ProxyFetcher`] is the port trait.  Implement it to pull proxies from any
4//! source (remote HTTP list, database, commercial API, etc.) and integrate with
5//! [`ProxyManager`] via [`load_from_fetcher`].
6//!
7//! The built-in [`FreeListFetcher`] downloads plain-text `host:port` proxy
8//! lists from public URLs (e.g. the `TheSpeedX/PROXY-List` feeds on GitHub)
9//! and parses them into [`Proxy`] records.  It is suitable for development,
10//! testing, and low-stakes scraping where proxy quality is less critical.
11//!
12//! ## Example — load from a free list and populate the pool
13//!
14//! ```no_run
15//! use std::sync::Arc;
16//! use stygian_proxy::{
17//!     ProxyManager,
18//!     storage::MemoryProxyStore,
19//!     fetcher::{FreeListFetcher, ProxyFetcher, FreeListSource},
20//! };
21//!
22//! # async fn run() -> stygian_proxy::error::ProxyResult<()> {
23//! let fetcher = FreeListFetcher::new(vec![
24//!     FreeListSource::TheSpeedXHttp,
25//! ]);
26//!
27//! let manager = ProxyManager::builder()
28//!     .storage(Arc::new(MemoryProxyStore::default()))
29//!     .build()?;
30//! let loaded = stygian_proxy::fetcher::load_from_fetcher(&manager, &fetcher).await?;
31//! println!("Loaded {loaded} proxies");
32//! # Ok(())
33//! # }
34//! ```
35
36use std::time::Duration;
37
38use async_trait::async_trait;
39use futures::future::join_all;
40use reqwest::Client;
41use serde::Deserialize;
42use tracing::{debug, warn};
43
44use crate::{
45    Proxy, ProxyManager, ProxyType,
46    error::{ProxyError, ProxyResult},
47};
48
49// ─── Port trait ───────────────────────────────────────────────────────────────
50
51/// A source that can produce a list of [`Proxy`] records asynchronously.
52///
53/// Implement this trait to integrate any proxy source (remote HTTP list,
54/// commercial API, database, file) with [`load_from_fetcher`].
55///
56/// # Example
57///
58/// ```
59/// use async_trait::async_trait;
60/// use stygian_proxy::{Proxy, ProxyType};
61/// use stygian_proxy::fetcher::ProxyFetcher;
62/// use stygian_proxy::error::ProxyResult;
63/// use stygian_proxy::types::{IpClass, ProxyCapabilities, TargetVendorCompatibility};
64///
65/// struct MyStaticFetcher;
66///
67/// #[async_trait]
68/// impl ProxyFetcher for MyStaticFetcher {
69///     async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
70///         Ok(vec![Proxy {
71///             url: "http://192.168.1.1:8080".into(),
72///             proxy_type: ProxyType::Http,
73///             username: None,
74///             password: None,
75///             weight: 1,
76///             tags: vec!["static".into()],
77///             capabilities: ProxyCapabilities::default(),
78///             ip_class: IpClass::Isp,
79///             target_compatibility: TargetVendorCompatibility::default(),
80///         }])
81///     }
82/// }
83/// ```
84#[async_trait]
85pub trait ProxyFetcher: Send + Sync {
86    /// Fetch the current proxy list.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`ProxyError::FetchFailed`] if the source is unreachable or
91    /// returns malformed data.
92    async fn fetch(&self) -> ProxyResult<Vec<Proxy>>;
93}
94
95// ─── Free-list sources ────────────────────────────────────────────────────────
96
97/// A well-known free/public proxy list feed.
98///
99/// These lists are community-maintained and quality varies.  They are suitable
100/// for development and testing.  For production use, prefer a commercial
101/// provider adapter.
102///
103/// # Example
104///
105/// ```
106/// use stygian_proxy::fetcher::FreeListSource;
107/// let _src = FreeListSource::TheSpeedXHttp;
108/// ```
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum FreeListSource {
112    /// HTTP proxies from `TheSpeedX/PROXY-List` (GitHub, plain `host:port`).
113    TheSpeedXHttp,
114    #[cfg(feature = "socks")]
115    /// SOCKS4 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
116    TheSpeedXSocks4,
117    #[cfg(feature = "socks")]
118    /// SOCKS5 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
119    TheSpeedXSocks5,
120    /// HTTP proxies from `clarketm/proxy-list` (GitHub, plain `host:port`).
121    ClarketmHttp,
122    /// Mixed HTTP proxies from `openproxylist.xyz`.
123    OpenProxyListHttp,
124    /// A custom URL.  Content must be one `host:port` entry per line.
125    Custom {
126        /// The URL to fetch.
127        url: String,
128        /// The [`ProxyType`] to assign all parsed entries.
129        proxy_type: ProxyType,
130    },
131}
132
133impl FreeListSource {
134    const fn url(&self) -> &str {
135        match self {
136            Self::TheSpeedXHttp => {
137                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt"
138            }
139            #[cfg(feature = "socks")]
140            Self::TheSpeedXSocks4 => {
141                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt"
142            }
143            #[cfg(feature = "socks")]
144            Self::TheSpeedXSocks5 => {
145                "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt"
146            }
147            Self::ClarketmHttp => {
148                "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt"
149            }
150            Self::OpenProxyListHttp => "https://openproxylist.xyz/http.txt",
151            Self::Custom { url, .. } => url.as_str(),
152        }
153    }
154
155    const fn proxy_type(&self) -> ProxyType {
156        match self {
157            Self::TheSpeedXHttp | Self::ClarketmHttp | Self::OpenProxyListHttp => ProxyType::Http,
158            #[cfg(feature = "socks")]
159            Self::TheSpeedXSocks4 => ProxyType::Socks4,
160            #[cfg(feature = "socks")]
161            Self::TheSpeedXSocks5 => ProxyType::Socks5,
162            Self::Custom { proxy_type, .. } => *proxy_type,
163        }
164    }
165}
166
167// ─── FreeListFetcher ──────────────────────────────────────────────────────────
168
169/// Fetches plain-text `host:port` proxy lists from one or more public URLs.
170///
171/// Each source is fetched concurrently.  Lines that do not parse as valid
172/// `host:port` entries are silently skipped.  An empty or unreachable source
173/// logs a warning but does not fail the entire fetch — at least one source
174/// must return results for the call to succeed.
175///
176/// # Example
177///
178/// ```no_run
179/// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource, ProxyFetcher};
180///
181/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
182/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
183/// let proxies = fetcher.fetch().await?;
184/// println!("Got {} proxies", proxies.len());
185/// # Ok(())
186/// # }
187/// ```
188pub struct FreeListFetcher {
189    sources: Vec<FreeListSource>,
190    client: Client,
191    tags: Vec<String>,
192}
193
194impl FreeListFetcher {
195    /// Create a fetcher for the given sources with default HTTP client settings
196    /// (10 s timeout, TLS enabled).
197    ///
198    /// # Example
199    ///
200    /// ```
201    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
202    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
203    /// ```
204    #[must_use]
205    pub fn new(sources: Vec<FreeListSource>) -> Self {
206        let client = Client::builder()
207            .timeout(Duration::from_secs(10))
208            .build()
209            .unwrap_or_else(|e| {
210                warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
211                Client::default()
212            });
213        Self {
214            sources,
215            client,
216            tags: vec!["free-list".into()],
217        }
218    }
219
220    /// Replace the internal HTTP client with a TLS-profiled one.
221    ///
222    /// Proxy-list fetch requests will carry a browser TLS fingerprint and
223    /// matching `Accept` / `Sec-CH-UA` headers.
224    ///
225    /// Only available with the `tls-profiled` feature.
226    ///
227    /// # Example
228    ///
229    /// ```no_run
230    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
231    /// use stygian_proxy::http_client::{ProfiledRequestMode, ProfiledRequester};
232    ///
233    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
234    /// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
235    ///     .with_profiled_client(ProfiledRequester::chrome_mode(ProfiledRequestMode::Preset)?);
236    /// # Ok(())
237    /// # }
238    /// ```
239    #[cfg(feature = "tls-profiled")]
240    #[must_use]
241    pub fn with_profiled_client(
242        mut self,
243        requester: crate::http_client::ProfiledRequester,
244    ) -> Self {
245        self.client = requester.client().clone();
246        drop(requester);
247        self
248    }
249
250    /// Build and attach a profile-mode-based requester.
251    ///
252    /// Uses Chrome 131 as the baseline browser identity and applies `mode`
253    /// to TLS control mapping.
254    ///
255    /// Only available when the `tls-profiled` feature is enabled.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`crate::error::ProxyError::ConfigError`] if the profiled
260    /// requester cannot be constructed.
261    #[cfg(feature = "tls-profiled")]
262    pub fn with_profiled_mode(
263        self,
264        mode: crate::types::ProfiledRequestMode,
265    ) -> crate::error::ProxyResult<Self> {
266        let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
267            .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
268        Ok(self.with_profiled_client(requester))
269    }
270
271    /// Attach extra tags to every proxy produced by this fetcher.
272    ///
273    /// # Example
274    ///
275    /// ```
276    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
277    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
278    ///     .with_tags(vec!["dev".into(), "http".into()]);
279    /// ```
280    #[must_use]
281    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
282        self.tags.extend(tags);
283        self
284    }
285
286    /// Parse one `host:port` line, including bracketed IPv6 addresses.
287    fn parse_host_port_line(line: &str) -> Option<(String, u16)> {
288        let line = line.trim();
289        if line.is_empty() || line.starts_with('#') {
290            return None;
291        }
292
293        let (host, port_str) = if line.starts_with('[') {
294            let end = line.find(']')?;
295            let host = line.get(..=end)?.trim();
296            let remainder = line.get(end + 1..)?.trim();
297            let (_, port_str) = remainder.rsplit_once(':')?;
298            (host, port_str.trim())
299        } else {
300            let (host, port_str) = line.rsplit_once(':')?;
301            let host = host.trim();
302            if host.contains(':') {
303                return None;
304            }
305            (host, port_str.trim())
306        };
307
308        if host.is_empty() || host == "[]" {
309            return None;
310        }
311
312        let port = port_str.parse::<u16>().ok()?;
313        if port == 0 {
314            return None;
315        }
316
317        Some((host.to_string(), port))
318    }
319
320    /// Fetch a single source, returning parsed proxies (empty on failure).
321    async fn fetch_source(&self, source: &FreeListSource) -> Vec<Proxy> {
322        let url = source.url();
323        let proxy_type = source.proxy_type();
324
325        let body = match self
326            .client
327            .get(url)
328            .timeout(Duration::from_secs(10))
329            .send()
330            .await
331        {
332            Ok(resp) if resp.status().is_success() => match resp.text().await {
333                Ok(t) => t,
334                Err(e) => {
335                    warn!("Failed to read body from {url}: {e}");
336                    return vec![];
337                }
338            },
339            Ok(resp) => {
340                warn!(
341                    "Non-success status {} fetching proxy list from {url}",
342                    resp.status()
343                );
344                return vec![];
345            }
346            Err(e) => {
347                warn!("Failed to fetch proxy list from {url}: {e}");
348                return vec![];
349            }
350        };
351
352        let proxies: Vec<Proxy> = body
353            .lines()
354            .filter_map(|line| {
355                let (host, port) = Self::parse_host_port_line(line)?;
356                let scheme = match proxy_type {
357                    ProxyType::Http => "http",
358                    ProxyType::Https => "https",
359                    #[cfg(feature = "socks")]
360                    ProxyType::Socks4 => "socks4",
361                    #[cfg(feature = "socks")]
362                    ProxyType::Socks5 => "socks5",
363                    ProxyType::CdnEdge => "https",
364                };
365                Some(Proxy {
366                    url: format!("{scheme}://{host}:{port}"),
367                    proxy_type,
368                    username: None,
369                    password: None,
370                    weight: 1,
371                    tags: self.tags.clone(),
372                    capabilities: crate::types::ProxyCapabilities {
373                        is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
374                        ..Default::default()
375                    },
376                    ip_class: crate::types::IpClass::Datacenter,
377                    target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
378                    ),
379                })
380            })
381            .collect();
382
383        debug!(source = url, count = proxies.len(), "Fetched proxy list");
384        proxies
385    }
386}
387
388#[async_trait]
389impl ProxyFetcher for FreeListFetcher {
390    async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
391        if self.sources.is_empty() {
392            return Err(ProxyError::ConfigError(
393                "no sources configured for FreeListFetcher".into(),
394            ));
395        }
396
397        // Drive all source fetches concurrently.
398        let results = join_all(self.sources.iter().map(|s| self.fetch_source(s))).await;
399        let all: Vec<Proxy> = results.into_iter().flatten().collect();
400
401        if all.is_empty() {
402            return Err(ProxyError::FetchFailed {
403                origin: self
404                    .sources
405                    .iter()
406                    .map(FreeListSource::url)
407                    .collect::<Vec<_>>()
408                    .join(", "),
409                message: "all sources returned empty or failed".into(),
410            });
411        }
412
413        Ok(all)
414    }
415}
416
417// ─── FreeAPIProxies adapter ──────────────────────────────────────────────────
418
419/// Fetches proxies from a JSON API compatible with FreeAPIProxies-style
420/// payloads.
421///
422/// The adapter accepts either a top-level array payload or an object payload
423/// with `data` or `results` arrays.  Optional query parameters (`limit`,
424/// `protocol`, `country`) are appended to the endpoint URL when set.
425///
426/// # Example
427///
428/// ```no_run
429/// use stygian_proxy::fetcher::{FreeApiProxiesFetcher, ProxyFetcher};
430///
431/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
432/// let fetcher = FreeApiProxiesFetcher::new()
433///     .with_limit(100)
434///     .with_protocol_filter("http")
435///     .with_country_filter("US");
436/// let proxies = fetcher.fetch().await?;
437/// println!("Got {} proxies", proxies.len());
438/// # Ok(())
439/// # }
440/// ```
441pub struct FreeApiProxiesFetcher {
442    endpoint: String,
443    client: Client,
444    tags: Vec<String>,
445    /// Maximum number of proxies to request from the API.
446    limit: Option<u32>,
447    /// Protocol filter sent as a query parameter (e.g. `"http"`, `"socks5"`).
448    protocol_filter: Option<String>,
449    /// ISO 3166-1 alpha-2 country code filter (e.g. `"US"`, `"DE"`).
450    country_filter: Option<String>,
451}
452
453#[derive(Debug, Deserialize)]
454#[serde(untagged)]
455enum FreeApiProxiesResponse {
456    List(Vec<FreeApiProxyRecord>),
457    Data { data: Vec<FreeApiProxyRecord> },
458    Results { results: Vec<FreeApiProxyRecord> },
459}
460
461impl FreeApiProxiesResponse {
462    fn into_records(self) -> Vec<FreeApiProxyRecord> {
463        match self {
464            Self::List(records)
465            | Self::Data { data: records }
466            | Self::Results { results: records } => records,
467        }
468    }
469}
470
471#[derive(Debug, Deserialize)]
472struct FreeApiProxyRecord {
473    #[serde(default, alias = "ip", alias = "host")]
474    address_host: String,
475    #[serde(default)]
476    port: Option<u16>,
477    #[serde(default, alias = "proxy", alias = "address")]
478    address: Option<String>,
479    #[serde(default, alias = "protocol", alias = "type", alias = "proxy_type")]
480    protocol: Option<String>,
481    #[serde(default)]
482    username: Option<String>,
483    #[serde(default)]
484    password: Option<String>,
485    #[serde(default, alias = "countryCode", alias = "country_code")]
486    country_code: Option<String>,
487}
488
489impl FreeApiProxiesFetcher {
490    const DEFAULT_ENDPOINT: &str = "https://freeapiproxies.azurewebsites.net/";
491
492    /// Create a `FreeAPIProxies` fetcher using the default endpoint.
493    #[must_use]
494    pub fn new() -> Self {
495        Self::with_endpoint(Self::DEFAULT_ENDPOINT)
496    }
497
498    /// Create a `FreeAPIProxies` fetcher using a custom JSON endpoint.
499    #[must_use]
500    pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
501        let client = Client::builder()
502            .timeout(Duration::from_secs(10))
503            .build()
504            .unwrap_or_else(|e| {
505                warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
506                Client::default()
507            });
508
509        Self {
510            endpoint: endpoint.into(),
511            client,
512            tags: vec!["freeapiproxies".into()],
513            limit: None,
514            protocol_filter: None,
515            country_filter: None,
516        }
517    }
518
519    /// Attach extra tags to every proxy produced by this fetcher.
520    #[must_use]
521    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
522        self.tags.extend(tags);
523        self
524    }
525
526    /// Set the maximum number of proxies to request from the API.
527    ///
528    /// Appended to the request as `?limit=<n>`.  Ignored when `None`.
529    ///
530    /// # Example
531    ///
532    /// ```
533    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
534    /// let _f = FreeApiProxiesFetcher::new().with_limit(50);
535    /// ```
536    #[must_use]
537    pub const fn with_limit(mut self, limit: u32) -> Self {
538        self.limit = Some(limit);
539        self
540    }
541
542    /// Filter by proxy protocol on the server side.
543    ///
544    /// Appended to the request as `?protocol=<value>`.  Common values are
545    /// `"http"`, `"https"`, `"socks4"`, and `"socks5"`.
546    ///
547    /// # Example
548    ///
549    /// ```
550    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
551    /// let _f = FreeApiProxiesFetcher::new().with_protocol_filter("http");
552    /// ```
553    #[must_use]
554    pub fn with_protocol_filter(mut self, protocol: impl Into<String>) -> Self {
555        self.protocol_filter = Some(protocol.into());
556        self
557    }
558
559    /// Filter by ISO 3166-1 alpha-2 country code on the server side.
560    ///
561    /// Appended to the request as `?country=<value>` (uppercased).
562    ///
563    /// # Example
564    ///
565    /// ```
566    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
567    /// let _f = FreeApiProxiesFetcher::new().with_country_filter("US");
568    /// ```
569    #[must_use]
570    pub fn with_country_filter(mut self, country_code: impl Into<String>) -> Self {
571        self.country_filter = Some(country_code.into().to_ascii_uppercase());
572        self
573    }
574
575    /// Build the full request URL with any configured query parameters.
576    fn request_url(&self) -> String {
577        let mut params: Vec<(&str, String)> = Vec::new();
578        if let Some(limit) = self.limit {
579            params.push(("limit", limit.to_string()));
580        }
581        if let Some(ref protocol) = self.protocol_filter {
582            params.push(("protocol", protocol.clone()));
583        }
584        if let Some(ref country) = self.country_filter {
585            params.push(("country", country.clone()));
586        }
587        if params.is_empty() {
588            return self.endpoint.clone();
589        }
590        let qs = params
591            .iter()
592            .enumerate()
593            .fold(String::new(), |mut acc, (i, (k, v))| {
594                use std::fmt::Write as _;
595                let sep = if i == 0 { "?" } else { "&" };
596                let _ = write!(acc, "{sep}{k}={v}");
597                acc
598            });
599        format!("{}{qs}", self.endpoint)
600    }
601
602    fn protocol_to_proxy_type(protocol: Option<&str>) -> Option<ProxyType> {
603        let normalized = protocol.map(str::trim).map(str::to_ascii_lowercase);
604        match normalized.as_deref() {
605            None | Some("" | "http") => Some(ProxyType::Http),
606            Some("https") => Some(ProxyType::Https),
607            Some("cdn" | "cdn_edge") => Some(ProxyType::CdnEdge),
608            #[cfg(feature = "socks")]
609            Some("socks" | "socks5") => Some(ProxyType::Socks5),
610            #[cfg(feature = "socks")]
611            Some("socks4") => Some(ProxyType::Socks4),
612            _ => None,
613        }
614    }
615
616    fn parse_address(record: &FreeApiProxyRecord) -> Option<(String, u16)> {
617        if let Some(address) = record.address.as_deref() {
618            if let Some((host, port)) = FreeListFetcher::parse_host_port_line(address) {
619                return Some((host, port));
620            }
621
622            if let Ok(url) = reqwest::Url::parse(address)
623                && let Some(port) = url.port_or_known_default()
624            {
625                return Some((url.host_str()?.to_string(), port));
626            }
627        }
628
629        let host = record.address_host.trim();
630        let port = record.port?;
631        if host.is_empty() || port == 0 {
632            return None;
633        }
634        Some((host.to_string(), port))
635    }
636
637    fn record_to_proxy(&self, record: FreeApiProxyRecord) -> Option<Proxy> {
638        let proxy_type = Self::protocol_to_proxy_type(record.protocol.as_deref())?;
639        let (host, port) = Self::parse_address(&record)?;
640
641        let scheme = match proxy_type {
642            ProxyType::Http => "http",
643            ProxyType::Https => "https",
644            #[cfg(feature = "socks")]
645            ProxyType::Socks4 => "socks4",
646            #[cfg(feature = "socks")]
647            ProxyType::Socks5 => "socks5",
648            ProxyType::CdnEdge => "https",
649        };
650
651        let mut tags = self.tags.clone();
652        if let Some(country_code) = record.country_code.as_deref()
653            && !country_code.trim().is_empty()
654        {
655            tags.push(format!(
656                "country:{}",
657                country_code.trim().to_ascii_uppercase()
658            ));
659        }
660
661        Some(Proxy {
662            url: format!("{scheme}://{host}:{port}"),
663            proxy_type,
664            username: record.username.filter(|v| !v.trim().is_empty()),
665            password: record.password.filter(|v| !v.trim().is_empty()),
666            weight: 1,
667            tags,
668            capabilities: crate::types::ProxyCapabilities {
669                is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
670                ..Default::default()
671            },
672            ip_class: crate::types::IpClass::Datacenter,
673            target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
674        })
675    }
676
677    fn parse_payload(&self, body: &str) -> ProxyResult<Vec<Proxy>> {
678        let response: FreeApiProxiesResponse =
679            serde_json::from_str(body).map_err(|e| ProxyError::FetchFailed {
680                origin: self.endpoint.clone(),
681                message: format!("invalid freeapiproxies json payload: {e}"),
682            })?;
683
684        let proxies: Vec<Proxy> = response
685            .into_records()
686            .into_iter()
687            .filter_map(|record| self.record_to_proxy(record))
688            .collect();
689
690        if proxies.is_empty() {
691            return Err(ProxyError::FetchFailed {
692                origin: self.endpoint.clone(),
693                message: "freeapiproxies payload contained no usable proxies".into(),
694            });
695        }
696
697        Ok(proxies)
698    }
699}
700
701impl Default for FreeApiProxiesFetcher {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707#[async_trait]
708impl ProxyFetcher for FreeApiProxiesFetcher {
709    async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
710        let url = self.request_url();
711        let body = self
712            .client
713            .get(&url)
714            .timeout(Duration::from_secs(10))
715            .send()
716            .await
717            .map_err(|e| ProxyError::FetchFailed {
718                origin: url.clone(),
719                message: e.to_string(),
720            })?
721            .error_for_status()
722            .map_err(|e| ProxyError::FetchFailed {
723                origin: url.clone(),
724                message: e.to_string(),
725            })?
726            .text()
727            .await
728            .map_err(|e| ProxyError::FetchFailed {
729                origin: url.clone(),
730                message: e.to_string(),
731            })?;
732
733        self.parse_payload(&body)
734    }
735}
736
737// ─── Helper ───────────────────────────────────────────────────────────────────
738
739/// Fetch proxies from `fetcher` and add them all to `manager`.
740///
741/// Returns the number of proxies successfully added.  Individual `add_proxy`
742/// failures (e.g. duplicate URL) are logged as warnings and do not abort the
743/// load.
744///
745/// # Errors
746///
747/// Returns any [`ProxyError`] emitted by `fetcher.fetch()` if the fetcher
748/// itself fails.
749///
750/// # Example
751///
752/// ```no_run
753/// use std::sync::Arc;
754/// use stygian_proxy::{ProxyManager, storage::MemoryProxyStore, fetcher::{FreeListFetcher, FreeListSource, load_from_fetcher}};
755///
756/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
757/// let manager = ProxyManager::builder()
758///     .storage(Arc::new(MemoryProxyStore::default()))
759///     .build()?;
760/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
761/// let n = load_from_fetcher(&manager, &fetcher).await?;
762/// println!("Loaded {n} proxies");
763/// # Ok(())
764/// # }
765/// ```
766pub async fn load_from_fetcher(
767    manager: &ProxyManager,
768    fetcher: &dyn ProxyFetcher,
769) -> ProxyResult<usize> {
770    let proxies = fetcher.fetch().await?;
771    let total = proxies.len();
772    let mut loaded = 0usize;
773
774    for proxy in proxies {
775        match manager.add_proxy(proxy).await {
776            Ok(_) => loaded += 1,
777            Err(e) => warn!("Skipped proxy during load: {e}"),
778        }
779    }
780
781    debug!(total, loaded, "Proxy list loaded into manager");
782    Ok(loaded)
783}
784
785// ─── DnsTxtFetcher ───────────────────────────────────────────────────────────
786
787/// Fetches proxy endpoints from DNS TXT records.
788///
789/// Each TXT record at the configured zone should encode one proxy entry using
790/// the following colon-delimited format:
791///
792/// ```text
793/// host:port                        HTTP proxy, no auth
794/// host:port:https                  HTTPS proxy, no auth
795/// host:port:socks5                 SOCKS5 proxy (requires socks feature)
796/// host:port:socks5:user:pass       SOCKS5 proxy with auth
797/// host:port:http:user:pass         HTTP proxy with auth
798/// host:port:cdn_edge               CDN edge proxy
799/// host:port:cdn_edge:cloudflare    CDN edge proxy with provider metadata
800/// [::1]:port:http                  IPv6 host in bracket notation
801/// ```
802///
803/// Records that do not match the format are silently skipped.
804///
805/// # Example
806///
807/// ```no_run
808/// use stygian_proxy::fetcher::{DnsTxtFetcher, ProxyFetcher};
809///
810/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
811/// let fetcher = DnsTxtFetcher::new("proxies.internal.example.com");
812/// let proxies = fetcher.fetch().await?;
813/// println!("Discovered {} proxies via DNS", proxies.len());
814/// # Ok(())
815/// # }
816/// ```
817#[cfg(feature = "dns-fetcher")]
818pub struct DnsTxtFetcher {
819    zone: String,
820    allowed_zone_suffixes: Vec<String>,
821    lookup_timeout: Duration,
822    tags: Vec<String>,
823}
824
825#[cfg(feature = "dns-fetcher")]
826impl DnsTxtFetcher {
827    const DEFAULT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
828    const MAX_JOINED_TXT_RECORD_LEN: usize = 2 * 1024;
829
830    /// Create a fetcher that queries TXT records for `zone`.
831    ///
832    /// `zone` is a DNS name such as `"proxies.internal.example.com"`.
833    pub fn new(zone: impl Into<String>) -> Self {
834        Self {
835            zone: zone.into().trim().to_string(),
836            allowed_zone_suffixes: Vec::new(),
837            lookup_timeout: Self::DEFAULT_LOOKUP_TIMEOUT,
838            tags: vec!["dns-txt".into()],
839        }
840    }
841
842    /// Restrict DNS discovery to zones that end with one of the provided suffixes.
843    ///
844    /// A zone is accepted when it exactly matches a suffix or is a child of it.
845    /// For example, with suffix `"internal.example.com"`, both
846    /// `"internal.example.com"` and `"proxy.internal.example.com"` are allowed.
847    ///
848    /// # Example
849    ///
850    /// ```
851    /// use stygian_proxy::fetcher::DnsTxtFetcher;
852    ///
853    /// let _fetcher = DnsTxtFetcher::new("proxies.internal.example.com")
854    ///     .with_allowed_zone_suffixes(vec!["internal.example.com".to_string()]);
855    /// ```
856    #[must_use]
857    pub fn with_allowed_zone_suffixes(mut self, suffixes: Vec<String>) -> Self {
858        self.allowed_zone_suffixes = suffixes;
859        self
860    }
861
862    /// Set the timeout used for a single DNS TXT lookup.
863    ///
864    /// # Example
865    ///
866    /// ```
867    /// use std::time::Duration;
868    /// use stygian_proxy::fetcher::DnsTxtFetcher;
869    ///
870    /// let _fetcher = DnsTxtFetcher::new("proxies.internal.example.com")
871    ///     .with_lookup_timeout(Duration::from_secs(3));
872    /// ```
873    #[must_use]
874    pub const fn with_lookup_timeout(mut self, timeout: Duration) -> Self {
875        self.lookup_timeout = timeout;
876        self
877    }
878
879    /// Attach extra tags to every proxy discovered via this fetcher.
880    #[must_use]
881    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
882        self.tags.extend(tags);
883        self
884    }
885
886    fn normalize_zone(value: &str) -> String {
887        value.trim().trim_end_matches('.').to_ascii_lowercase()
888    }
889
890    fn validate_dns_zone(value: &str) -> bool {
891        let zone = Self::normalize_zone(value);
892        if zone.is_empty() || zone.len() > 253 {
893            return false;
894        }
895
896        for label in zone.split('.') {
897            if label.is_empty() || label.len() > 63 {
898                return false;
899            }
900
901            let bytes = label.as_bytes();
902            let first = bytes.first().copied();
903            let last = bytes.last().copied();
904            if first == Some(b'-') || last == Some(b'-') {
905                return false;
906            }
907
908            if !bytes
909                .iter()
910                .copied()
911                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
912            {
913                return false;
914            }
915        }
916
917        true
918    }
919
920    fn zone_allowed(&self, zone: &str) -> bool {
921        if self.allowed_zone_suffixes.is_empty() {
922            return true;
923        }
924
925        let zone = Self::normalize_zone(zone);
926        self.allowed_zone_suffixes.iter().any(|suffix| {
927            let suffix = Self::normalize_zone(suffix);
928            zone == suffix || zone.ends_with(&format!(".{suffix}"))
929        })
930    }
931
932    /// Parse a single TXT record string into a [`Proxy`].
933    ///
934    /// The optional 4th field is an operator-provided
935    /// [`crate::types::IpClass`] tag (e.g. `"mobile"`, `"isp"`,
936    /// `"residential"`, `"datacenter"`). Unknown or missing values
937    /// default to [`crate::types::IpClass::Datacenter`] so DNS-discovered
938    /// entries fail-securely when a `require_ip_class` capability gate
939    /// is in play.
940    fn parse_record(&self, record: &str) -> Option<Proxy> {
941        let record = record.trim();
942        if record.is_empty() || record.starts_with('#') {
943            return None;
944        }
945        let (host, port, remainder) = Self::parse_host_port_remainder(record)?;
946        if host.is_empty() || port == 0 {
947            return None;
948        }
949        // 4 fields: type, [user, pass, ip_class] or [provider, _, ip_class] for cdn_edge.
950        // splitn(4, ':') so the password (or provider) can keep its colons.
951        let parts: Vec<&str> = remainder.splitn(4, ':').collect();
952        let type_str = parts.first().map_or("http", |s| s.trim());
953        match type_str.to_ascii_lowercase().as_str() {
954            "cdn_edge" | "cdn" => Some(self.build_cdn_edge_proxy(&host, port, &parts)),
955            type_str => Some(self.build_typed_proxy(&host, port, type_str, &parts)),
956        }
957    }
958
959    /// Extract `(host, port, remainder)` from a TXT record, supporting
960    /// bracketed IPv6 addresses.
961    fn parse_host_port_remainder(record: &str) -> Option<(String, u16, &str)> {
962        if let Some(rest) = record.strip_prefix('[') {
963            let end = rest.find(']')?;
964            let host = format!("[{}]", rest.get(..end)?);
965            let after = rest.get(end + 1..).unwrap_or("").trim_start_matches(':');
966            let colon = after.find(':').unwrap_or(after.len());
967            let port: u16 = after.get(..colon)?.trim().parse().ok()?;
968            let rem = after.get(colon + 1..).unwrap_or("");
969            Some((host, port, rem))
970        } else {
971            let first = record.find(':')?;
972            let host = record.get(..first)?.trim().to_string();
973            let rest = record.get(first + 1..)?;
974            let second = rest.find(':').unwrap_or(rest.len());
975            let port: u16 = rest.get(..second)?.trim().parse().ok()?;
976            let rem = rest.get(second + 1..).unwrap_or("");
977            Some((host, port, rem))
978        }
979    }
980
981    /// Build the `Proxy` for a `cdn_edge` TXT record.
982    ///
983    /// `parts` is the 4-field split: `[type, provider, _, ip_class]`.
984    fn build_cdn_edge_proxy(&self, host: &str, port: u16, parts: &[&str]) -> Proxy {
985        let provider = parts
986            .get(1)
987            .copied()
988            .map(str::trim)
989            .filter(|s| !s.is_empty())
990            .map(str::to_string);
991        let ip_class = parts
992            .get(2)
993            .copied()
994            .map(str::trim)
995            .and_then(crate::types::IpClass::from_label)
996            .unwrap_or(crate::types::IpClass::Datacenter);
997        Proxy {
998            url: format!("https://{host}:{port}"),
999            proxy_type: ProxyType::CdnEdge,
1000            username: None,
1001            password: None,
1002            weight: 1,
1003            tags: self.tags.clone(),
1004            capabilities: crate::types::ProxyCapabilities {
1005                is_cdn_edge: true,
1006                cdn_provider: provider,
1007                ..Default::default()
1008            },
1009            ip_class,
1010            target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
1011        }
1012    }
1013
1014    /// Build the `Proxy` for a typed (non-cdn-edge) TXT record.
1015    ///
1016    /// `parts` is the 4-field split: `[type, user, pass, ip_class]`.
1017    /// `type_str` is the lowercased type tag.
1018    fn build_typed_proxy(&self, host: &str, port: u16, type_str: &str, parts: &[&str]) -> Proxy {
1019        let proxy_type = match type_str {
1020            "https" => ProxyType::Https,
1021            #[cfg(feature = "socks")]
1022            "socks5" | "socks" => ProxyType::Socks5,
1023            #[cfg(feature = "socks")]
1024            "socks4" => ProxyType::Socks4,
1025            _ => ProxyType::Http,
1026        };
1027        let scheme = match proxy_type {
1028            ProxyType::Http => "http",
1029            ProxyType::Https => "https",
1030            #[cfg(feature = "socks")]
1031            ProxyType::Socks4 => "socks4",
1032            #[cfg(feature = "socks")]
1033            ProxyType::Socks5 => "socks5",
1034            ProxyType::CdnEdge => "https",
1035        };
1036        let username = parts
1037            .get(1)
1038            .copied()
1039            .map(str::trim)
1040            .filter(|s| !s.is_empty())
1041            .map(str::to_string);
1042        let password = parts
1043            .get(2)
1044            .copied()
1045            .map(str::trim)
1046            .filter(|s| !s.is_empty())
1047            .map(str::to_string);
1048        let ip_class = parts
1049            .get(3)
1050            .copied()
1051            .map(str::trim)
1052            .and_then(crate::types::IpClass::from_label)
1053            .unwrap_or(crate::types::IpClass::Datacenter);
1054        Proxy {
1055            url: format!("{scheme}://{host}:{port}"),
1056            proxy_type,
1057            username,
1058            password,
1059            weight: 1,
1060            tags: self.tags.clone(),
1061            capabilities: crate::types::ProxyCapabilities::default(),
1062            ip_class,
1063            target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
1064        }
1065    }
1066}
1067
1068#[cfg(feature = "dns-fetcher")]
1069#[async_trait]
1070impl ProxyFetcher for DnsTxtFetcher {
1071    async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
1072        use hickory_resolver::TokioResolver;
1073        use tokio::time::timeout;
1074
1075        let zone = Self::normalize_zone(&self.zone);
1076        if !Self::validate_dns_zone(&zone) {
1077            return Err(ProxyError::ConfigError(format!(
1078                "invalid DNS zone for DnsTxtFetcher: '{}'",
1079                self.zone
1080            )));
1081        }
1082
1083        for suffix in &self.allowed_zone_suffixes {
1084            if !Self::validate_dns_zone(suffix) {
1085                return Err(ProxyError::ConfigError(format!(
1086                    "invalid allowed DNS zone suffix for DnsTxtFetcher: '{suffix}'"
1087                )));
1088            }
1089        }
1090
1091        if !self.zone_allowed(&zone) {
1092            return Err(ProxyError::FetchFailed {
1093                origin: zone.clone(),
1094                message: format!("DNS zone '{zone}' rejected by trusted suffix policy"),
1095            });
1096        }
1097
1098        let resolver = TokioResolver::builder_tokio()
1099            .map_err(|e| ProxyError::ConfigError(format!("DNS resolver init failed: {e}")))?
1100            .build()
1101            .map_err(|e| ProxyError::ConfigError(format!("DNS resolver build failed: {e}")))?;
1102
1103        let lookup = timeout(self.lookup_timeout, resolver.txt_lookup(zone.as_str()))
1104            .await
1105            .map_err(|_| ProxyError::FetchFailed {
1106                origin: zone.clone(),
1107                message: format!(
1108                    "DNS TXT lookup timed out for '{}' after {:?}",
1109                    zone, self.lookup_timeout
1110                ),
1111            })?
1112            .map_err(|e| ProxyError::FetchFailed {
1113                origin: zone.clone(),
1114                message: format!("DNS TXT lookup failed for '{zone}': {e}"),
1115            })?;
1116
1117        let mut proxies: Vec<Proxy> = Vec::new();
1118        for record in lookup.answers() {
1119            // Each TXT record may contain multiple character-strings; join them,
1120            // but cap the total size to avoid pathological oversized inputs.
1121            let hickory_resolver::proto::rr::RData::TXT(txt) = &record.data else {
1122                continue;
1123            };
1124            let mut record_str = String::new();
1125            let mut skipped_for_size = false;
1126            for bytes in &txt.txt_data {
1127                if let Ok(fragment) = std::str::from_utf8(bytes) {
1128                    if record_str.len().saturating_add(fragment.len())
1129                        > Self::MAX_JOINED_TXT_RECORD_LEN
1130                    {
1131                        skipped_for_size = true;
1132                        break;
1133                    }
1134                    record_str.push_str(fragment);
1135                }
1136            }
1137            if skipped_for_size {
1138                warn!(
1139                    zone = %zone,
1140                    max_len = Self::MAX_JOINED_TXT_RECORD_LEN,
1141                    "skipping oversized DNS TXT record",
1142                );
1143                continue;
1144            }
1145
1146            if let Some(proxy) = self.parse_record(&record_str) {
1147                proxies.push(proxy);
1148            }
1149        }
1150
1151        if proxies.is_empty() {
1152            return Err(ProxyError::FetchFailed {
1153                origin: zone.clone(),
1154                message: format!("no valid proxy records found in DNS TXT for '{zone}'"),
1155            });
1156        }
1157
1158        debug!(
1159            zone = %zone,
1160            count = proxies.len(),
1161            "fetched proxy list from DNS TXT",
1162        );
1163        Ok(proxies)
1164    }
1165}
1166
1167// ─── Tests ────────────────────────────────────────────────────────────────────
1168
1169#[cfg(test)]
1170#[allow(
1171    clippy::unwrap_used,
1172    clippy::expect_used,
1173    clippy::panic,
1174    clippy::indexing_slicing
1175)] // test assertions on Option/Result are deterministic
1176mod tests {
1177    use super::*;
1178
1179    // ── DnsTxtFetcher::parse_record ───────────────────────────────────────────
1180
1181    #[cfg(feature = "dns-fetcher")]
1182    #[allow(
1183        clippy::unwrap_used,
1184        clippy::expect_used,
1185        clippy::panic,
1186        clippy::indexing_slicing
1187    )]
1188    mod dns_txt {
1189        use super::*;
1190
1191        fn fetcher() -> DnsTxtFetcher {
1192            DnsTxtFetcher::new("proxies.example.com")
1193        }
1194
1195        #[test]
1196        fn parse_http_host_port() {
1197            let proxy = fetcher().parse_record("10.0.1.5:8080").unwrap();
1198            assert_eq!(proxy.url, "http://10.0.1.5:8080");
1199            assert_eq!(proxy.proxy_type, ProxyType::Http);
1200            assert!(proxy.username.is_none());
1201            assert!(proxy.password.is_none());
1202        }
1203
1204        #[test]
1205        fn parse_https_record() {
1206            let proxy = fetcher().parse_record("10.0.1.5:443:https").unwrap();
1207            assert_eq!(proxy.url, "https://10.0.1.5:443");
1208            assert_eq!(proxy.proxy_type, ProxyType::Https);
1209        }
1210
1211        #[test]
1212        fn parse_cdn_edge_with_provider() {
1213            let proxy = fetcher()
1214                .parse_record("edge.cdn.example.com:443:cdn_edge:cloudflare")
1215                .unwrap();
1216            assert_eq!(proxy.url, "https://edge.cdn.example.com:443");
1217            assert_eq!(proxy.proxy_type, ProxyType::CdnEdge);
1218            assert!(proxy.capabilities.is_cdn_edge);
1219            assert_eq!(
1220                proxy.capabilities.cdn_provider.as_deref(),
1221                Some("cloudflare")
1222            );
1223        }
1224
1225        #[test]
1226        fn parse_cdn_edge_without_provider() {
1227            let proxy = fetcher()
1228                .parse_record("cdn.example.com:443:cdn_edge")
1229                .unwrap();
1230            assert!(proxy.capabilities.is_cdn_edge);
1231            assert!(proxy.capabilities.cdn_provider.is_none());
1232        }
1233
1234        #[test]
1235        fn parse_auth_fields() {
1236            let proxy = fetcher()
1237                .parse_record("10.0.0.1:3128:http:alice:secret")
1238                .unwrap();
1239            assert_eq!(proxy.username.as_deref(), Some("alice"));
1240            assert_eq!(proxy.password.as_deref(), Some("secret"));
1241        }
1242
1243        #[test]
1244        fn parse_ipv6_bracketed() {
1245            let proxy = fetcher().parse_record("[::1]:8080").unwrap();
1246            assert_eq!(proxy.url, "http://[::1]:8080");
1247        }
1248
1249        #[test]
1250        fn parse_empty_record_returns_none() {
1251            assert!(fetcher().parse_record("").is_none());
1252            assert!(fetcher().parse_record("   ").is_none());
1253        }
1254
1255        #[test]
1256        fn parse_comment_record_returns_none() {
1257            assert!(fetcher().parse_record("# comment line").is_none());
1258        }
1259
1260        #[test]
1261        fn parse_invalid_port_returns_none() {
1262            assert!(fetcher().parse_record("10.0.0.1:notaport").is_none());
1263        }
1264
1265        /// T95: a TXT record with an explicit `mobile` `IpClass` tag
1266        /// produces a `Mobile` proxy; missing/unknown tags fall back to
1267        /// `Datacenter` (fail-secure).
1268        #[test]
1269        fn parse_record_with_mobile_ip_class_tag() {
1270            // user:pass:ipclass — splitn(4, ':') → ["http", "alice", "secret", "mobile"]
1271            let proxy = fetcher()
1272                .parse_record("10.0.0.1:3128:http:alice:secret:mobile")
1273                .unwrap();
1274            assert_eq!(proxy.username.as_deref(), Some("alice"));
1275            assert_eq!(proxy.password.as_deref(), Some("secret"));
1276            assert_eq!(proxy.ip_class, crate::types::IpClass::Mobile);
1277        }
1278
1279        #[test]
1280        fn parse_record_with_isp_ip_class_tag() {
1281            // The 4-field layout is host:port:type:user:pass:ipclass
1282            // (6 colon-separated parts). With splitn(4, ':') on the
1283            // type+remainder, "http:::isp" → parts = ["http", "", "", "isp"]
1284            // so ipclass is the 4th part (ipclass). Leaving user/pass empty
1285            // (two consecutive colons) is the canonical way to declare
1286            // an unauthenticated, ipclass-tagged DNS record.
1287            let proxy = fetcher().parse_record("10.0.0.1:3128:http:::isp").unwrap();
1288            assert_eq!(proxy.ip_class, crate::types::IpClass::Isp);
1289            // username / password remain empty.
1290            assert!(proxy.username.is_none());
1291            assert!(proxy.password.is_none());
1292        }
1293
1294        #[test]
1295        fn parse_record_without_ip_class_defaults_to_datacenter() {
1296            let proxy = fetcher().parse_record("10.0.0.1:3128").unwrap();
1297            assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1298            // Free-list default: every vendor is Blocked.
1299            assert_eq!(
1300                proxy
1301                    .target_compatibility
1302                    .get(crate::types::VendorId::DataDome),
1303                Some(crate::types::TrustTier::Blocked)
1304            );
1305        }
1306
1307        #[test]
1308        fn parse_record_with_unknown_ip_class_label_defaults_to_datacenter() {
1309            // Unknown labels must not panic — fail-secure fallback. The
1310            // 4th field is "quantum" (not a valid IpClass label) so the
1311            // parser falls back to Datacenter.
1312            let proxy = fetcher()
1313                .parse_record("10.0.0.1:3128:http:::quantum")
1314                .unwrap();
1315            assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1316        }
1317
1318        #[test]
1319        fn parse_cdn_edge_with_ip_class_tag() {
1320            // cdn_edge layout: host:port:cdn_edge:provider:ipclass.
1321            let proxy = fetcher()
1322                .parse_record("edge.example.com:443:cdn_edge:cloudflare:mobile")
1323                .unwrap();
1324            assert!(proxy.capabilities.is_cdn_edge);
1325            assert_eq!(
1326                proxy.capabilities.cdn_provider.as_deref(),
1327                Some("cloudflare")
1328            );
1329            assert_eq!(proxy.ip_class, crate::types::IpClass::Mobile);
1330        }
1331
1332        #[test]
1333        fn parse_cdn_edge_without_ip_class_defaults_to_datacenter() {
1334            let proxy = fetcher()
1335                .parse_record("edge.example.com:443:cdn_edge:cloudflare")
1336                .unwrap();
1337            assert!(proxy.capabilities.is_cdn_edge);
1338            assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1339        }
1340
1341        #[test]
1342        fn validate_dns_zone_accepts_valid_names() {
1343            assert!(DnsTxtFetcher::validate_dns_zone(
1344                "proxies.internal.example.com"
1345            ));
1346            assert!(DnsTxtFetcher::validate_dns_zone(
1347                "PROXIES.INTERNAL.EXAMPLE.COM"
1348            ));
1349            assert!(DnsTxtFetcher::validate_dns_zone(
1350                "proxy-1.internal.example.com"
1351            ));
1352            assert!(DnsTxtFetcher::validate_dns_zone(
1353                "proxy.internal.example.com."
1354            ));
1355        }
1356
1357        #[test]
1358        fn validate_dns_zone_rejects_invalid_names() {
1359            assert!(!DnsTxtFetcher::validate_dns_zone(""));
1360            assert!(!DnsTxtFetcher::validate_dns_zone("   "));
1361            assert!(!DnsTxtFetcher::validate_dns_zone("-bad.example.com"));
1362            assert!(!DnsTxtFetcher::validate_dns_zone("bad-.example.com"));
1363            assert!(!DnsTxtFetcher::validate_dns_zone("bad..example.com"));
1364            assert!(!DnsTxtFetcher::validate_dns_zone("bad_zone.example.com"));
1365        }
1366
1367        #[test]
1368        fn zone_allowed_matches_exact_or_child_suffix() {
1369            let fetcher = DnsTxtFetcher::new("proxies.internal.example.com")
1370                .with_allowed_zone_suffixes(vec!["internal.example.com".to_string()]);
1371            assert!(fetcher.zone_allowed("internal.example.com"));
1372            assert!(fetcher.zone_allowed("proxies.internal.example.com"));
1373            assert!(!fetcher.zone_allowed("example.com"));
1374            assert!(!fetcher.zone_allowed("evilinternal.example.com"));
1375        }
1376
1377        #[test]
1378        fn normalize_zone_trims_and_lowercases() {
1379            assert_eq!(
1380                DnsTxtFetcher::normalize_zone("  Proxies.Internal.Example.Com.  "),
1381                "proxies.internal.example.com"
1382            );
1383        }
1384    }
1385
1386    #[test]
1387    fn free_api_proxies_fetcher_request_url_no_params() {
1388        let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
1389        assert_eq!(f.request_url(), "https://example.test/api");
1390    }
1391
1392    #[test]
1393    fn free_api_proxies_fetcher_request_url_with_params() {
1394        let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api")
1395            .with_limit(50)
1396            .with_protocol_filter("http")
1397            .with_country_filter("us");
1398        let url = f.request_url();
1399        assert!(url.contains("limit=50"), "expected limit param in {url}");
1400        assert!(
1401            url.contains("protocol=http"),
1402            "expected protocol param in {url}"
1403        );
1404        assert!(
1405            url.contains("country=US"),
1406            "expected country uppercased in {url}"
1407        );
1408        assert!(url.starts_with("https://example.test/api?"), "missing ?");
1409    }
1410
1411    #[test]
1412    fn free_api_proxies_fetcher_country_filter_uppercased() {
1413        let f = FreeApiProxiesFetcher::new().with_country_filter("de");
1414        assert_eq!(f.country_filter.as_deref(), Some("DE"));
1415    }
1416
1417    /// Integration test — hits the live `FreeAPIProxies` endpoint.
1418    /// Run with: `cargo test -p stygian-proxy --all-features -- --ignored`
1419    #[test]
1420    #[ignore = "requires live network access to freeapiproxies.azurewebsites.net"]
1421    fn free_api_proxies_fetcher_live_fetch() -> std::result::Result<(), Box<dyn std::error::Error>>
1422    {
1423        let fetcher = FreeApiProxiesFetcher::new().with_limit(20);
1424        let rt = tokio::runtime::Builder::new_current_thread()
1425            .enable_all()
1426            .build()
1427            .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1428        let proxies = rt.block_on(fetcher.fetch())?;
1429        assert!(
1430            !proxies.is_empty(),
1431            "expected at least one proxy from live endpoint"
1432        );
1433        for proxy in &proxies {
1434            assert!(
1435                proxy.url.starts_with("http://")
1436                    || proxy.url.starts_with("https://")
1437                    || proxy.url.starts_with("socks4://")
1438                    || proxy.url.starts_with("socks5://"),
1439                "unexpected proxy url scheme: {}",
1440                proxy.url
1441            );
1442        }
1443        Ok(())
1444    }
1445
1446    #[test]
1447    fn free_list_source_url_is_nonempty() {
1448        #[cfg(not(feature = "socks"))]
1449        let sources = vec![
1450            FreeListSource::TheSpeedXHttp,
1451            FreeListSource::ClarketmHttp,
1452            FreeListSource::OpenProxyListHttp,
1453            FreeListSource::Custom {
1454                url: "https://example.com/proxies.txt".into(),
1455                proxy_type: ProxyType::Http,
1456            },
1457        ];
1458        #[cfg(feature = "socks")]
1459        let sources = {
1460            let mut s = vec![
1461                FreeListSource::TheSpeedXHttp,
1462                FreeListSource::ClarketmHttp,
1463                FreeListSource::OpenProxyListHttp,
1464                FreeListSource::Custom {
1465                    url: "https://example.com/proxies.txt".into(),
1466                    proxy_type: ProxyType::Http,
1467                },
1468            ];
1469            s.extend([
1470                FreeListSource::TheSpeedXSocks4,
1471                FreeListSource::TheSpeedXSocks5,
1472            ]);
1473            s
1474        };
1475        for src in &sources {
1476            assert!(
1477                !src.url().is_empty(),
1478                "FreeListSource::{src:?} has empty URL"
1479            );
1480        }
1481    }
1482
1483    #[test]
1484    fn free_list_source_proxy_types() {
1485        assert_eq!(FreeListSource::TheSpeedXHttp.proxy_type(), ProxyType::Http);
1486        #[cfg(feature = "socks")]
1487        assert_eq!(
1488            FreeListSource::TheSpeedXSocks4.proxy_type(),
1489            ProxyType::Socks4
1490        );
1491        #[cfg(feature = "socks")]
1492        assert_eq!(
1493            FreeListSource::TheSpeedXSocks5.proxy_type(),
1494            ProxyType::Socks5
1495        );
1496        assert_eq!(FreeListSource::ClarketmHttp.proxy_type(), ProxyType::Http);
1497    }
1498
1499    #[test]
1500    fn free_api_proxies_fetcher_parses_array_payload() -> crate::error::ProxyResult<()> {
1501        let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1502        let body = r#"
1503[
1504    {"host":"1.2.3.4","port":8080,"protocol":"http","countryCode":"us"},
1505    {"address":"5.6.7.8:8443","protocol":"https"}
1506]
1507"#;
1508
1509        let proxies = fetcher.parse_payload(body)?;
1510        assert_eq!(proxies.len(), 2);
1511        assert_eq!(
1512            proxies.first().map(|proxy| proxy.url.as_str()),
1513            Some("http://1.2.3.4:8080")
1514        );
1515        assert_eq!(
1516            proxies.get(1).map(|proxy| proxy.url.as_str()),
1517            Some("https://5.6.7.8:8443")
1518        );
1519        Ok(())
1520    }
1521
1522    #[test]
1523    fn free_api_proxies_fetcher_parses_wrapped_results_payload() -> crate::error::ProxyResult<()> {
1524        let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1525        let body = r#"
1526{
1527    "results": [
1528        {"ip":"9.9.9.9","port":3128,"type":"http"}
1529    ]
1530}
1531"#;
1532
1533        let proxies = fetcher.parse_payload(body)?;
1534        assert_eq!(proxies.len(), 1);
1535        assert_eq!(
1536            proxies.first().map(|proxy| proxy.url.as_str()),
1537            Some("http://9.9.9.9:3128")
1538        );
1539        Ok(())
1540    }
1541
1542    #[test]
1543    fn free_list_fetcher_parse_valid_lines() {
1544        let fetcher = FreeListFetcher::new(vec![]);
1545        // Test the parsing logic directly by calling parse on synthetic text.
1546        let text = "1.2.3.4:8080\n# comment\n\nbad-line\n5.6.7.8:3128\n[2001:db8::1]:8081\n";
1547        let parsed: Vec<Proxy> = text
1548            .lines()
1549            .filter_map(|line| {
1550                let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
1551                Some(Proxy {
1552                    url: format!("http://{host}:{port}"),
1553                    proxy_type: ProxyType::Http,
1554                    username: None,
1555                    password: None,
1556                    weight: 1,
1557                    tags: fetcher.tags.clone(),
1558                    capabilities: crate::types::ProxyCapabilities::default(),
1559                    ip_class: crate::types::IpClass::Datacenter,
1560                    target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
1561                    ),
1562                })
1563            })
1564            .collect();
1565
1566        assert_eq!(parsed.len(), 3);
1567        assert_eq!(
1568            parsed.first().map(|proxy| proxy.url.as_str()),
1569            Some("http://1.2.3.4:8080")
1570        );
1571        assert_eq!(
1572            parsed.get(1).map(|proxy| proxy.url.as_str()),
1573            Some("http://5.6.7.8:3128")
1574        );
1575        assert_eq!(
1576            parsed.get(2).map(|proxy| proxy.url.as_str()),
1577            Some("http://[2001:db8::1]:8081")
1578        );
1579    }
1580
1581    #[test]
1582    fn free_list_fetcher_with_tags_extends() {
1583        let f = FreeListFetcher::new(vec![]).with_tags(vec!["custom".into()]);
1584        assert!(f.tags.contains(&"free-list".to_string()));
1585        assert!(f.tags.contains(&"custom".to_string()));
1586    }
1587
1588    #[test]
1589    fn free_list_fetcher_skips_invalid_port() {
1590        assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:notaport").is_none());
1591        assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:0").is_none());
1592        assert!(FreeListFetcher::parse_host_port_line(":8080").is_none());
1593        assert!(FreeListFetcher::parse_host_port_line("2001:db8::1:8080").is_none());
1594    }
1595
1596    /// T95: every free-list ingest path tags proxies as `IpClass::Datacenter`
1597    /// regardless of the upstream source content (the source data does not
1598    /// include an `IpClass` column).
1599    #[test]
1600    fn free_list_fetcher_ingest_tags_datacenter_and_blocked() {
1601        let fetcher = FreeListFetcher::new(vec![]);
1602        let text = "1.2.3.4:8080\n";
1603        let proxies: Vec<Proxy> = text
1604            .lines()
1605            .filter_map(|line| {
1606                let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
1607                Some(Proxy {
1608                    url: format!("http://{host}:{port}"),
1609                    proxy_type: ProxyType::Http,
1610                    username: None,
1611                    password: None,
1612                    weight: 1,
1613                    tags: fetcher.tags.clone(),
1614                    capabilities: crate::types::ProxyCapabilities::default(),
1615                    ip_class: crate::types::IpClass::Datacenter,
1616                    target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
1617                    ),
1618                })
1619            })
1620            .collect();
1621        assert_eq!(proxies.len(), 1);
1622        let proxy = proxies.first().expect("at least one proxy");
1623        assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1624        // Free-list pools must NOT satisfy a free-form vendor requirement
1625        // (every vendor is Blocked by default).
1626        assert_eq!(
1627            proxy
1628                .target_compatibility
1629                .get(crate::types::VendorId::DataDome),
1630            Some(crate::types::TrustTier::Blocked)
1631        );
1632        assert_eq!(
1633            proxy
1634                .target_compatibility
1635                .get(crate::types::VendorId::Akamai),
1636            Some(crate::types::TrustTier::Blocked)
1637        );
1638    }
1639
1640    /// T95: `FreeApiProxiesFetcher::parse_payload` (the JSON ingest path)
1641    /// also tags every proxy as `IpClass::Datacenter` and every vendor as
1642    /// `TrustTier::Blocked` — same fail-secure default as the plain-text
1643    /// free-list fetcher.
1644    #[test]
1645    fn free_api_proxies_fetcher_parse_tags_datacenter_and_blocked() {
1646        let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
1647        let body = r#"
1648[
1649    {"host":"1.2.3.4","port":8080,"protocol":"http"}
1650]
1651"#;
1652        let proxies = fetcher.parse_payload(body).expect("payload should parse");
1653        let proxy = proxies.first().expect("at least one proxy");
1654        assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1655        assert_eq!(
1656            proxy
1657                .target_compatibility
1658                .get(crate::types::VendorId::DataDome),
1659            Some(crate::types::TrustTier::Blocked)
1660        );
1661        assert_eq!(
1662            proxy
1663                .target_compatibility
1664                .get(crate::types::VendorId::Cloudflare),
1665            Some(crate::types::TrustTier::Blocked)
1666        );
1667    }
1668
1669    #[test]
1670    fn free_list_fetcher_empty_sources_is_config_error()
1671    -> std::result::Result<(), Box<dyn std::error::Error>> {
1672        let fetcher = FreeListFetcher::new(vec![]);
1673        let rt = tokio::runtime::Builder::new_current_thread()
1674            .enable_time()
1675            .build()
1676            .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1677        let err = rt
1678            .block_on(fetcher.fetch())
1679            .err()
1680            .ok_or_else(|| std::io::Error::other("empty sources should fail"))?;
1681        match err {
1682            ProxyError::ConfigError(msg) => {
1683                assert!(msg.contains("no sources configured"));
1684            }
1685            other => {
1686                return Err(
1687                    std::io::Error::other(format!("unexpected error variant: {other}")).into(),
1688                );
1689            }
1690        }
1691        Ok(())
1692    }
1693
1694    #[test]
1695    fn proxy_error_fetch_failed_display() {
1696        let e = ProxyError::FetchFailed {
1697            origin: "https://example.com".into(),
1698            message: "timed out".into(),
1699        };
1700        assert!(e.to_string().contains("https://example.com"));
1701        assert!(e.to_string().contains("timed out"));
1702    }
1703}