Skip to main content

stygian_proxy/storage/
mod.rs

1//! Storage port and in-memory adapter for proxy records.
2//!
3//! ## Ingest validation
4//!
5//! The [`MemoryProxyStore::add`] path runs
6//! [`crate::vendor_quirks::check`] on every URL before inserting the
7//! record. Hard-error quirks (e.g. `Crawlera` 8011 + `https://`) reject
8//! the URL outright; warning-severity quirks (e.g. `Bright Data` session
9//! format) are logged via `tracing::warn!` and the URL is accepted.
10//! See [`crate::vendor_quirks`] for the full quirk table and
11//! [`ProxyUrl`](crate::vendor_quirks::ProxyUrl) for the canonical URL
12//! parser.
13
14use async_trait::async_trait;
15use uuid::Uuid;
16
17use crate::error::ProxyResult;
18use crate::types::{Proxy, ProxyRecord};
19
20/// Abstract storage interface for persisting and querying proxy records.
21///
22/// Implementors must be `Send + Sync + 'static` to support concurrent access
23/// across async tasks. The trait is object-safe via [`macro@async_trait`].
24///
25/// # Example
26/// ```rust,no_run
27/// use stygian_proxy::storage::ProxyStoragePort;
28/// use stygian_proxy::types::{IpClass, Proxy, ProxyCapabilities, ProxyType, TargetVendorCompatibility};
29/// use uuid::Uuid;
30///
31/// async fn demo(store: &dyn ProxyStoragePort) {
32///     let proxy = Proxy {
33///         url: "http://proxy.example.com:8080".into(),
34///         proxy_type: ProxyType::Http,
35///         username: None,
36///         password: None,
37///         weight: 1,
38///         tags: vec![],
39///         capabilities: ProxyCapabilities::default(),
40///         ip_class: IpClass::Unknown,
41///         target_compatibility: TargetVendorCompatibility::default(),
42///     };
43///     let record = store.add(proxy).await.unwrap();
44///     let _ = store.get(record.id).await.unwrap();
45/// }
46/// ```
47#[async_trait]
48pub trait ProxyStoragePort: Send + Sync + 'static {
49    /// Add a new proxy to the store and return its [`ProxyRecord`].
50    async fn add(&self, proxy: Proxy) -> ProxyResult<ProxyRecord>;
51
52    /// Remove a proxy by its UUID. Returns an error if the ID is not found.
53    async fn remove(&self, id: Uuid) -> ProxyResult<()>;
54
55    /// Return all stored proxy records.
56    async fn list(&self) -> ProxyResult<Vec<ProxyRecord>>;
57
58    /// Fetch a single proxy record by UUID.
59    async fn get(&self, id: Uuid) -> ProxyResult<ProxyRecord>;
60
61    /// Record the outcome of a request through a proxy.
62    ///
63    /// - `success`: whether the request succeeded.
64    /// - `latency_ms`: elapsed time in milliseconds.
65    async fn update_metrics(&self, id: Uuid, success: bool, latency_ms: u64) -> ProxyResult<()>;
66
67    /// Return all stored proxy records paired with their live metrics reference.
68    ///
69    /// Used by [`ProxyManager`](crate::manager::ProxyManager) when building
70    /// [`ProxyCandidate`](crate::strategy::ProxyCandidate) slices so that
71    /// latency-aware strategies (e.g. least-used) see up-to-date counters.
72    async fn list_with_metrics(&self) -> ProxyResult<Vec<(ProxyRecord, Arc<ProxyMetrics>)>>;
73}
74
75/// Convenience alias for a heap-allocated, type-erased [`ProxyStoragePort`].
76pub type BoxedProxyStorage = Box<dyn ProxyStoragePort>;
77
78// ─────────────────────────────────────────────────────────────────────────────
79// URL validation helper
80// ─────────────────────────────────────────────────────────────────────────────
81
82/// Validate a proxy URL: scheme must be recognised, host must be non-empty,
83/// and the explicit port (if present) must be in [1, 65535].
84///
85/// Hard-error vendor quirks (e.g. `Crawlera` 8011 + `https://`) reject
86/// the URL outright. Warning-severity quirks (e.g. `Bright Data`
87/// session-id format) are surfaced via `tracing::warn!` and the URL
88/// is accepted. See [`crate::vendor_quirks`] for the full quirk
89/// table.
90fn validate_proxy_url(url: &str) -> ProxyResult<()> {
91    use crate::error::ProxyError;
92    use crate::vendor_quirks::{self, ParseError, QuirkSeverity};
93
94    // ── T100: structural validation via the canonical `ProxyUrl` parser ───
95    //
96    // The `ProxyUrl::parse` function is the single source of truth for
97    // scheme/host/port/user-info structure. We re-emit the same error
98    // surface as before (ProxyError::InvalidProxyUrl) so the public
99    // behaviour and the existing `invalid_url_rejected` /
100    // `invalid_url_empty_host` tests remain stable.
101    let parsed = vendor_quirks::ProxyUrl::parse(url).map_err(|e| match e {
102        ParseError::MissingSchemeSeparator(_) => ProxyError::InvalidProxyUrl {
103            url: url.to_owned(),
104            reason: "missing scheme separator '://'".into(),
105        },
106        ParseError::UnsupportedScheme(ref s, _) => ProxyError::InvalidProxyUrl {
107            url: url.to_owned(),
108            reason: format!("unsupported scheme '{s}'"),
109        },
110        ParseError::EmptyHost(_) => ProxyError::InvalidProxyUrl {
111            url: url.to_owned(),
112            reason: "empty host".into(),
113        },
114        ParseError::NonNumericPort(ref p, _) => ProxyError::InvalidProxyUrl {
115            url: url.to_owned(),
116            reason: format!("non-numeric port '{p}'"),
117        },
118        ParseError::PortOutOfRange { ref port, .. } => ProxyError::InvalidProxyUrl {
119            url: url.to_owned(),
120            reason: format!("port {port} is out of range [1, 65535]"),
121        },
122        ParseError::UnclosedIpv6Bracket(_) => ProxyError::InvalidProxyUrl {
123            url: url.to_owned(),
124            reason: "unclosed IPv6 bracket in host".into(),
125        },
126    })?;
127
128    // ── T100: vendor quirk check ───────────────────────────────────────────
129    //
130    // `vendor_quirks::check` is the canonical entry point for
131    // provider-specific rules. We:
132    //
133    // 1. Reject URLs that match an `Error`-severity quirk (e.g. the
134    //    Crawlera 8011 + `https://` WRONG_VERSION_NUMBER trap).
135    // 2. Log Warning-severity quirks via `tracing::warn!` — the URL is
136    //    accepted, but operators see the message in the ingest log.
137    // 3. Record Info-severity quirks via `tracing::info!` (no reject).
138    //
139    // The match is on `host:port` only; the password component of the
140    // URL is never inspected, logged, or echoed.
141    let quirks = vendor_quirks::check(&parsed);
142    for m in &quirks {
143        match m.severity {
144            QuirkSeverity::Error => {
145                return Err(ProxyError::InvalidProxyUrl {
146                    url: url.to_owned(),
147                    reason: m.description.to_owned(),
148                });
149            }
150            QuirkSeverity::Warning => {
151                tracing::warn!(
152                    proxy_url_host = %parsed.host,
153                    proxy_url_port = ?parsed.port,
154                    quirk_host_suffix = m.host_suffix,
155                    observed_scheme = m.observed_scheme.as_str(),
156                    required_scheme = m.required_scheme.as_str(),
157                    quirk_description = m.description,
158                    "proxy URL matches a vendor quirk warning (URL accepted)"
159                );
160            }
161            QuirkSeverity::Info => {
162                tracing::info!(
163                    proxy_url_host = %parsed.host,
164                    proxy_url_port = ?parsed.port,
165                    quirk_host_suffix = m.host_suffix,
166                    quirk_description = m.description,
167                    "proxy URL matches a vendor quirk info record"
168                );
169            }
170        }
171    }
172
173    Ok(())
174}
175
176/// Validate the optional geo-metadata fields on
177/// [`crate::types::ProxyCapabilities`].
178///
179/// Runs [`validate_asn`](crate::types::validate_asn),
180/// [`validate_city`](crate::types::validate_city), and
181/// [`validate_postal_code`](crate::types::validate_postal_code) on the
182/// populated fields and returns the first failure encountered, or
183/// `Ok(())` when every populated field passes. `None` fields are
184/// always accepted (the existing "no enrichment" default).
185///
186/// Called from the storage adapter's `add` path so free-list fetchers
187/// and operator-supplied `add_proxy_with_metadata` calls reject
188/// malformed values before the record reaches the pool.
189fn validate_geo_metadata(caps: &crate::types::ProxyCapabilities) -> ProxyResult<()> {
190    use crate::types::{validate_asn, validate_city, validate_postal_code};
191
192    if let Some(asn) = caps.asn {
193        validate_asn(asn)?;
194    }
195    if let Some(ref city) = caps.city {
196        validate_city(city)?;
197    }
198    if let Some(ref postal) = caps.postal_code {
199        validate_postal_code(postal)?;
200    }
201    Ok(())
202}
203
204// ─────────────────────────────────────────────────────────────────────────────
205// MemoryProxyStore
206// ─────────────────────────────────────────────────────────────────────────────
207
208use std::collections::HashMap;
209use tokio::sync::RwLock;
210
211use crate::types::ProxyMetrics;
212use std::sync::Arc;
213
214type StoreMap = HashMap<Uuid, (ProxyRecord, Arc<ProxyMetrics>)>;
215
216/// In-memory implementation of [`ProxyStoragePort`].
217///
218/// Uses a `tokio::sync::RwLock`-guarded `HashMap` for thread-safe access.
219/// Metrics are updated via atomic operations, so only a **read** lock is
220/// needed for [`update_metrics`](MemoryProxyStore::update_metrics) calls —
221/// write contention stays low even under heavy concurrent load.
222///
223/// # Example
224/// ```
225/// # tokio_test::block_on(async {
226/// use stygian_proxy::storage::{MemoryProxyStore, ProxyStoragePort};
227/// use stygian_proxy::types::{IpClass, Proxy, ProxyCapabilities, ProxyType, TargetVendorCompatibility};
228///
229/// let store = MemoryProxyStore::default();
230/// let proxy = Proxy { url: "http://proxy.example.com:8080".into(), proxy_type: ProxyType::Http,
231///                     username: None, password: None, weight: 1, tags: vec![],
232///                     capabilities: ProxyCapabilities::default(),
233///                     ip_class: IpClass::Unknown,
234///                     target_compatibility: TargetVendorCompatibility::default() };
235/// let record = store.add(proxy).await.unwrap();
236/// assert_eq!(store.list().await.unwrap().len(), 1);
237/// store.remove(record.id).await.unwrap();
238/// assert!(store.list().await.unwrap().is_empty());
239/// # })
240/// ```
241#[derive(Debug, Default, Clone)]
242pub struct MemoryProxyStore {
243    inner: Arc<RwLock<StoreMap>>,
244}
245
246impl MemoryProxyStore {
247    /// Build a store pre-populated with `proxies`, validating each URL.
248    ///
249    /// Returns an error on the first invalid URL encountered.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`crate::error::ProxyError::StorageError`] when any supplied
254    /// proxy URL is invalid
255    /// or a duplicate of an existing entry.
256    pub async fn with_proxies(proxies: Vec<Proxy>) -> ProxyResult<Self> {
257        let store = Self::default();
258        for proxy in proxies {
259            store.add(proxy).await?;
260        }
261        Ok(store)
262    }
263}
264
265#[async_trait]
266impl ProxyStoragePort for MemoryProxyStore {
267    async fn add(&self, proxy: Proxy) -> ProxyResult<ProxyRecord> {
268        validate_proxy_url(&proxy.url)?;
269        validate_geo_metadata(&proxy.capabilities)?;
270        let record = ProxyRecord::new(proxy);
271        let metrics = Arc::new(ProxyMetrics::default());
272        self.inner
273            .write()
274            .await
275            .insert(record.id, (record.clone(), metrics));
276        Ok(record)
277    }
278
279    async fn remove(&self, id: Uuid) -> ProxyResult<()> {
280        self.inner
281            .write()
282            .await
283            .remove(&id)
284            .map(|_| ())
285            .ok_or_else(|| crate::error::ProxyError::StorageError(format!("proxy {id} not found")))
286    }
287
288    async fn list(&self) -> ProxyResult<Vec<ProxyRecord>> {
289        Ok(self
290            .inner
291            .read()
292            .await
293            .values()
294            .map(|(r, _)| r.clone())
295            .collect())
296    }
297
298    async fn get(&self, id: Uuid) -> ProxyResult<ProxyRecord> {
299        self.inner
300            .read()
301            .await
302            .get(&id)
303            .map(|(r, _)| r.clone())
304            .ok_or_else(|| crate::error::ProxyError::StorageError(format!("proxy {id} not found")))
305    }
306
307    async fn list_with_metrics(&self) -> ProxyResult<Vec<(ProxyRecord, Arc<ProxyMetrics>)>> {
308        Ok(self
309            .inner
310            .read()
311            .await
312            .values()
313            .map(|(r, m)| (r.clone(), Arc::clone(m)))
314            .collect())
315    }
316
317    async fn update_metrics(&self, id: Uuid, success: bool, latency_ms: u64) -> ProxyResult<()> {
318        use std::sync::atomic::Ordering;
319
320        let metrics = self
321            .inner
322            .read()
323            .await
324            .get(&id)
325            .map(|(_, m)| Arc::clone(m))
326            .ok_or_else(|| {
327                crate::error::ProxyError::StorageError(format!("proxy {id} not found"))
328            })?;
329
330        // Lock released before the atomic updates — no long critical section.
331        metrics.requests_total.fetch_add(1, Ordering::Relaxed);
332        if success {
333            metrics.successes.fetch_add(1, Ordering::Relaxed);
334        } else {
335            metrics.failures.fetch_add(1, Ordering::Relaxed);
336        }
337        metrics
338            .total_latency_ms
339            .fetch_add(latency_ms, Ordering::Relaxed);
340        Ok(())
341    }
342}
343
344// ─────────────────────────────────────────────────────────────────────────────
345// Tests
346// ─────────────────────────────────────────────────────────────────────────────
347
348#[cfg(test)]
349#[allow(
350    clippy::unwrap_used,
351    clippy::expect_used,
352    clippy::panic,
353    clippy::indexing_slicing
354)] // serde + storage round-trips and unwraps in test fixtures are deterministic
355mod tests {
356    use super::*;
357    use crate::types::ProxyType;
358    use std::sync::atomic::Ordering;
359
360    fn make_proxy(url: &str) -> Proxy {
361        Proxy {
362            url: url.into(),
363            proxy_type: ProxyType::Http,
364            username: None,
365            password: None,
366            weight: 1,
367            tags: vec![],
368            capabilities: crate::types::ProxyCapabilities::default(),
369            ip_class: crate::types::IpClass::Unknown,
370            target_compatibility: crate::types::TargetVendorCompatibility::default(),
371        }
372    }
373
374    #[tokio::test]
375    async fn add_list_remove() -> crate::error::ProxyResult<()> {
376        let store = MemoryProxyStore::default();
377        let r1 = store.add(make_proxy("http://a.test:8080")).await?;
378        let r2 = store.add(make_proxy("http://b.test:8080")).await?;
379        let r3 = store.add(make_proxy("http://c.test:8080")).await?;
380        assert_eq!(store.list().await?.len(), 3);
381        store.remove(r2.id).await?;
382        let remaining = store.list().await?;
383        assert_eq!(remaining.len(), 2);
384        let ids: Vec<_> = remaining.iter().map(|r| r.id).collect();
385        assert!(ids.contains(&r1.id));
386        assert!(ids.contains(&r3.id));
387        Ok(())
388    }
389
390    #[tokio::test]
391    async fn invalid_url_rejected() -> std::result::Result<(), Box<dyn std::error::Error>> {
392        let store = MemoryProxyStore::default();
393        let err = store
394            .add(make_proxy("not-a-url"))
395            .await
396            .err()
397            .ok_or_else(|| std::io::Error::other("invalid URL should be rejected"))?;
398        assert!(matches!(
399            err,
400            crate::error::ProxyError::InvalidProxyUrl { .. }
401        ));
402        Ok(())
403    }
404
405    #[tokio::test]
406    async fn invalid_url_empty_host() -> std::result::Result<(), Box<dyn std::error::Error>> {
407        let store = MemoryProxyStore::default();
408        let err = store
409            .add(make_proxy("http://:8080"))
410            .await
411            .err()
412            .ok_or_else(|| std::io::Error::other("empty host URL should be rejected"))?;
413        assert!(matches!(
414            err,
415            crate::error::ProxyError::InvalidProxyUrl { .. }
416        ));
417        Ok(())
418    }
419
420    // ── T98: geo-metadata ingest validation ────────────────────────────────
421
422    /// `asn = 0` must be rejected at ingest time.
423    #[tokio::test]
424    async fn invalid_geo_metadata_asn_zero_rejected()
425    -> std::result::Result<(), Box<dyn std::error::Error>> {
426        let store = MemoryProxyStore::default();
427        let mut p = make_proxy("http://cf.test:8080");
428        p.capabilities.asn = Some(0);
429        let err = store
430            .add(p)
431            .await
432            .err()
433            .ok_or_else(|| std::io::Error::other("asn=0 should be rejected"))?;
434        assert!(matches!(
435            err,
436            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
437        ));
438        Ok(())
439    }
440
441    /// `asn = u32::MAX` must be rejected at ingest time.
442    #[tokio::test]
443    async fn invalid_geo_metadata_asn_max_rejected()
444    -> std::result::Result<(), Box<dyn std::error::Error>> {
445        let store = MemoryProxyStore::default();
446        let mut p = make_proxy("http://cf.test:8080");
447        p.capabilities.asn = Some(u32::MAX);
448        let err = store
449            .add(p)
450            .await
451            .err()
452            .ok_or_else(|| std::io::Error::other("asn=u32::MAX should be rejected"))?;
453        assert!(matches!(
454            err,
455            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
456        ));
457        Ok(())
458    }
459
460    /// `city = ""` must be rejected at ingest time.
461    #[tokio::test]
462    async fn invalid_geo_metadata_empty_city_rejected()
463    -> std::result::Result<(), Box<dyn std::error::Error>> {
464        let store = MemoryProxyStore::default();
465        let mut p = make_proxy("http://cf.test:8080");
466        p.capabilities.city = Some(String::new());
467        let err = store
468            .add(p)
469            .await
470            .err()
471            .ok_or_else(|| std::io::Error::other("empty city should be rejected"))?;
472        assert!(matches!(
473            err,
474            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "city"
475        ));
476        Ok(())
477    }
478
479    /// `postal_code = ""` must be rejected at ingest time.
480    #[tokio::test]
481    async fn invalid_geo_metadata_empty_postal_code_rejected()
482    -> std::result::Result<(), Box<dyn std::error::Error>> {
483        let store = MemoryProxyStore::default();
484        let mut p = make_proxy("http://cf.test:8080");
485        p.capabilities.postal_code = Some(String::new());
486        let err = store
487            .add(p)
488            .await
489            .err()
490            .ok_or_else(|| std::io::Error::other("empty postal_code should be rejected"))?;
491        assert!(matches!(
492            err,
493            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "postal_code"
494        ));
495        Ok(())
496    }
497
498    /// Valid geo metadata is accepted.
499    #[tokio::test]
500    async fn valid_geo_metadata_accepted() -> std::result::Result<(), Box<dyn std::error::Error>> {
501        let store = MemoryProxyStore::default();
502        let mut p = make_proxy("http://cf.test:8080");
503        p.capabilities.asn = Some(13_335);
504        p.capabilities.city = Some("San Francisco".into());
505        p.capabilities.postal_code = Some("94110".into());
506        let record = store
507            .add(p)
508            .await
509            .map_err(|e| std::io::Error::other(format!("expected accept, got {e}")))?;
510        assert_eq!(record.proxy.capabilities.asn, Some(13_335));
511        Ok(())
512    }
513
514    #[tokio::test]
515    async fn concurrent_metrics_updates() -> std::result::Result<(), Box<dyn std::error::Error>> {
516        use tokio::task::JoinSet;
517
518        let store = Arc::new(MemoryProxyStore::default());
519        let record = store
520            .add(make_proxy("http://proxy.test:3128"))
521            .await
522            .map_err(|e| std::io::Error::other(format!("failed to add proxy: {e}")))?;
523        let id = record.id;
524
525        let mut tasks = JoinSet::new();
526        for i in 0u64..50 {
527            let s = Arc::clone(&store);
528            tasks.spawn(async move { s.update_metrics(id, i % 2 == 0, i * 10).await });
529        }
530        while let Some(res) = tasks.join_next().await {
531            let inner = res.map_err(|e| std::io::Error::other(format!("join failed: {e}")))?;
532            inner.map_err(|e| std::io::Error::other(format!("update_metrics failed: {e}")))?;
533        }
534
535        // Verify totals are internally consistent.
536        let guard = store.inner.read().await;
537        let metrics = guard
538            .get(&id)
539            .map(|(_, m)| Arc::clone(m))
540            .ok_or_else(|| std::io::Error::other("missing metrics for inserted proxy"))?;
541        drop(guard);
542
543        let total = metrics.requests_total.load(Ordering::Relaxed);
544        let successes = metrics.successes.load(Ordering::Relaxed);
545        let failures = metrics.failures.load(Ordering::Relaxed);
546        assert_eq!(total, 50);
547        assert_eq!(successes + failures, 50);
548        Ok(())
549    }
550
551    // ── T100: vendor-quirk ingest validation ────────────────────────────────
552
553    /// The headline `Crawlera` 8011 + `https://` trap must be rejected
554    /// at ingest time. The error reason is the static quirk description
555    /// (no credentials).
556    #[tokio::test]
557    async fn validate_crawlera_https_8011_rejected()
558    -> std::result::Result<(), Box<dyn std::error::Error>> {
559        let store = MemoryProxyStore::default();
560        let err = store
561            .add(make_proxy("https://user:secret@proxy.crawlera.com:8011"))
562            .await
563            .err()
564            .ok_or_else(|| std::io::Error::other("Crawlera 8011 + https:// must be rejected"))?;
565        match err {
566            crate::error::ProxyError::InvalidProxyUrl { url, reason } => {
567                assert_eq!(url, "https://user:secret@proxy.crawlera.com:8011");
568                // The reason must not echo the password.
569                assert!(
570                    !reason.contains("secret"),
571                    "reason leaked password: {reason}"
572                );
573                assert!(
574                    !reason.contains("user:secret"),
575                    "reason leaked credentials: {reason}"
576                );
577                // The reason must be the quirk description (snippet check).
578                assert!(
579                    reason.contains("WRONG_VERSION_NUMBER") || reason.contains("plain HTTP"),
580                    "reason should reference the quirk, got: {reason}"
581                );
582            }
583            other => panic!("expected InvalidProxyUrl, got {other:?}"),
584        }
585        Ok(())
586    }
587
588    /// The `Crawlera` 8011 + `http://` URL is the compliant form and
589    /// must be accepted.
590    #[tokio::test]
591    async fn validate_crawlera_http_8011_accepted()
592    -> std::result::Result<(), Box<dyn std::error::Error>> {
593        let store = MemoryProxyStore::default();
594        let record = store
595            .add(make_proxy("http://apikey:@proxy.crawlera.com:8011"))
596            .await
597            .map_err(|e| {
598                std::io::Error::other(format!("Crawlera 8011 + http:// must be accepted: {e}"))
599            })?;
600        assert_eq!(record.proxy.url, "http://apikey:@proxy.crawlera.com:8011");
601        Ok(())
602    }
603
604    /// The `Zyte` 8011 + `https://` trap must be rejected (same
605    /// `WRONG_VERSION_NUMBER` failure mode as `Crawlera`).
606    #[tokio::test]
607    async fn validate_zyte_https_8011_rejected()
608    -> std::result::Result<(), Box<dyn std::error::Error>> {
609        let store = MemoryProxyStore::default();
610        let err = store
611            .add(make_proxy("https://apikey:@proxy.zyte.com:8011"))
612            .await
613            .err()
614            .ok_or_else(|| std::io::Error::other("Zyte 8011 + https:// must be rejected"))?;
615        assert!(matches!(
616            err,
617            crate::error::ProxyError::InvalidProxyUrl { ref reason, .. }
618                if reason.contains("WRONG_VERSION_NUMBER") || reason.contains("plain HTTP")
619        ));
620        Ok(())
621    }
622
623    /// `Bright Data` quirk is a Warning — the URL is accepted and the
624    /// store adds the record.
625    #[tokio::test]
626    async fn validate_bright_data_warning_accepted()
627    -> std::result::Result<(), Box<dyn std::error::Error>> {
628        let store = MemoryProxyStore::default();
629        let record = store
630            .add(make_proxy(
631                "http://brd-customer-1-session-abc123@brd.superproxy.io:22225",
632            ))
633            .await
634            .map_err(|e| {
635                std::io::Error::other(format!("Bright Data Warning URL must be accepted: {e}"))
636            })?;
637        assert!(record.proxy.url.contains("brd.superproxy.io"));
638        Ok(())
639    }
640
641    /// `IPRoyal` quirk is a Warning — the URL is accepted.
642    #[tokio::test]
643    async fn validate_iproyal_warning_accepted()
644    -> std::result::Result<(), Box<dyn std::error::Error>> {
645        let store = MemoryProxyStore::default();
646        let record = store
647            .add(make_proxy(
648                "http://user-country-US:pass@residential.iproyal.com:12321",
649            ))
650            .await
651            .map_err(|e| {
652                std::io::Error::other(format!("IPRoyal Warning URL must be accepted: {e}"))
653            })?;
654        assert!(record.proxy.url.contains("iproyal.com"));
655        Ok(())
656    }
657
658    /// Unknown hosts produce zero false positives — the URL is
659    /// accepted without any quirk warnings.
660    #[tokio::test]
661    async fn validate_unknown_host_no_quirks_accepted()
662    -> std::result::Result<(), Box<dyn std::error::Error>> {
663        let store = MemoryProxyStore::default();
664        let record = store
665            .add(make_proxy(
666                "http://user:pass@some-unrelated-host.example:8080",
667            ))
668            .await
669            .map_err(|e| std::io::Error::other(format!("unrelated host must be accepted: {e}")))?;
670        assert!(record.proxy.url.contains("some-unrelated-host.example"));
671        Ok(())
672    }
673
674    /// The pre-existing structural URL validation must still reject
675    /// malformed URLs (e.g. missing scheme separator).
676    #[tokio::test]
677    async fn validate_preserves_structural_rejection()
678    -> std::result::Result<(), Box<dyn std::error::Error>> {
679        let store = MemoryProxyStore::default();
680        let err = store
681            .add(make_proxy("not-a-url"))
682            .await
683            .err()
684            .ok_or_else(|| std::io::Error::other("not-a-url must be rejected"))?;
685        assert!(matches!(
686            err,
687            crate::error::ProxyError::InvalidProxyUrl { ref reason, .. }
688                if reason.contains("missing scheme separator")
689        ));
690        Ok(())
691    }
692
693    /// The pre-existing empty-host rejection must still fire
694    /// (regression guard for the T100 refactor).
695    #[tokio::test]
696    async fn validate_preserves_empty_host_rejection()
697    -> std::result::Result<(), Box<dyn std::error::Error>> {
698        let store = MemoryProxyStore::default();
699        let err = store
700            .add(make_proxy("http://:8080"))
701            .await
702            .err()
703            .ok_or_else(|| std::io::Error::other("empty host must be rejected"))?;
704        assert!(matches!(
705            err,
706            crate::error::ProxyError::InvalidProxyUrl { ref reason, .. }
707                if reason.contains("empty host")
708        ));
709        Ok(())
710    }
711
712    /// `Crawlera` on a non-8011 port does NOT trigger the quirk (port
713    /// filter is applied before the scheme check).
714    #[tokio::test]
715    async fn validate_crawlera_non_8011_https_accepted()
716    -> std::result::Result<(), Box<dyn std::error::Error>> {
717        let store = MemoryProxyStore::default();
718        let record = store
719            .add(make_proxy("https://user:pass@proxy.crawlera.com:9000"))
720            .await
721            .map_err(|e| {
722                std::io::Error::other(format!("Crawlera 9000 + https:// must be accepted: {e}"))
723            })?;
724        assert!(record.proxy.url.contains("crawlera.com:9000"));
725        Ok(())
726    }
727}