Skip to main content

seer_core/bulk/
executor.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2use std::sync::Arc;
3use std::time::Duration;
4
5use futures::stream::{self, StreamExt};
6use serde::{Deserialize, Serialize};
7use tokio::sync::Mutex;
8use tokio::time::{sleep_until, Instant as TokioInstant};
9use tracing::{debug, info, instrument};
10
11use crate::availability::{AvailabilityChecker, AvailabilityResult};
12use crate::dns::{DnsRecord, DnsResolver, PropagationChecker, PropagationResult, RecordType};
13use crate::error::Result;
14use crate::lookup::{LookupResult, SmartLookup};
15use crate::rdap::{RdapClient, RdapResponse};
16use crate::ssl::{SslChecker, SslReport};
17use crate::status::{StatusClient, StatusResponse};
18use crate::whois::{WhoisClient, WhoisResponse};
19
20pub type ProgressCallback = Box<dyn Fn(usize, usize, &str) + Send + Sync>;
21
22/// Callback invoked with each [`BulkResult`] the moment it completes.
23///
24/// Results are delivered in completion order (matching the return-vec ordering
25/// contract on [`BulkExecutor::execute`]), so a UI can stream rows as they
26/// finish instead of waiting for the whole batch. The full vec is still
27/// returned for callers that want it.
28pub type ResultCallback = Box<dyn Fn(&BulkResult) + Send + Sync>;
29
30/// A single operation to perform in a bulk execution batch.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum BulkOperation {
34    Whois {
35        domain: String,
36    },
37    Rdap {
38        domain: String,
39    },
40    Dns {
41        domain: String,
42        record_type: RecordType,
43    },
44    Propagation {
45        domain: String,
46        record_type: RecordType,
47    },
48    Lookup {
49        domain: String,
50    },
51    Status {
52        domain: String,
53    },
54    Avail {
55        domain: String,
56    },
57    Info {
58        domain: String,
59    },
60    Ssl {
61        domain: String,
62    },
63}
64
65/// The data returned from a bulk operation (varies by operation type).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(tag = "result_type", content = "data", rename_all = "snake_case")]
68pub enum BulkResultData {
69    Whois(WhoisResponse),
70    Rdap(Box<RdapResponse>),
71    Dns(Vec<DnsRecord>),
72    Propagation(PropagationResult),
73    Lookup(LookupResult),
74    Status(StatusResponse),
75    Avail(AvailabilityResult),
76    Info(crate::domain_info::DomainInfo),
77    Ssl(SslReport),
78}
79
80/// Result of a single operation within a bulk execution.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct BulkResult {
83    pub operation: BulkOperation,
84    pub success: bool,
85    pub data: Option<BulkResultData>,
86    pub error: Option<String>,
87    pub duration_ms: u64,
88}
89
90/// Executes bulk operations concurrently with rate limiting.
91#[derive(Debug, Clone)]
92pub struct BulkExecutor {
93    concurrency: usize,
94    rate_limit_delay: Duration,
95    whois_client: WhoisClient,
96    rdap_client: RdapClient,
97    dns_resolver: DnsResolver,
98    propagation_checker: PropagationChecker,
99    smart_lookup: SmartLookup,
100    status_client: StatusClient,
101    availability_checker: AvailabilityChecker,
102    ssl_checker: SslChecker,
103}
104
105impl Default for BulkExecutor {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl BulkExecutor {
112    pub fn new() -> Self {
113        Self {
114            concurrency: 10,
115            rate_limit_delay: Duration::from_millis(100),
116            whois_client: WhoisClient::new(),
117            rdap_client: RdapClient::new(),
118            dns_resolver: DnsResolver::new(),
119            propagation_checker: PropagationChecker::new(),
120            smart_lookup: SmartLookup::new(),
121            status_client: StatusClient::new(),
122            availability_checker: AvailabilityChecker::new(),
123            ssl_checker: SslChecker::new(),
124        }
125    }
126
127    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
128        self.concurrency = concurrency.clamp(1, 50);
129        self
130    }
131
132    pub fn with_rate_limit(mut self, delay: Duration) -> Self {
133        self.rate_limit_delay = delay;
134        self
135    }
136
137    #[instrument(skip(self, operations, progress), fields(count = operations.len(), concurrency = self.concurrency))]
138    pub async fn execute(
139        &self,
140        operations: Vec<BulkOperation>,
141        progress: Option<ProgressCallback>,
142    ) -> Vec<BulkResult> {
143        self.execute_inner(operations, progress, None).await
144    }
145
146    /// Like [`execute`](Self::execute), but also invokes `on_result` with each
147    /// [`BulkResult`] as soon as it completes (completion order). Enables live
148    /// streaming of rows to a UI while the batch is still running.
149    pub async fn execute_streaming(
150        &self,
151        operations: Vec<BulkOperation>,
152        on_result: ResultCallback,
153    ) -> Vec<BulkResult> {
154        self.execute_inner(operations, None, Some(on_result)).await
155    }
156
157    #[instrument(skip(self, operations, progress, on_result), fields(count = operations.len(), concurrency = self.concurrency))]
158    async fn execute_inner(
159        &self,
160        operations: Vec<BulkOperation>,
161        progress: Option<ProgressCallback>,
162        on_result: Option<ResultCallback>,
163    ) -> Vec<BulkResult> {
164        let total = operations.len();
165        let completed = Arc::new(AtomicUsize::new(0));
166
167        info!("bulk operation started");
168        debug!(
169            total = total,
170            concurrency = self.concurrency,
171            "Starting bulk execution"
172        );
173
174        // Per-batch rate limiter. The naive `interval(...).tick().await` inside
175        // a `Mutex` would hold the lock across the await, serializing all
176        // dispatch through the lock at 1 / rate_limit_delay — exactly the
177        // regression v0.26.7 tried to fix from the other direction.
178        //
179        // Instead each task locks briefly to *claim* its dispatch slot (the
180        // next monotonically-increasing deadline at `rate_limit_delay`
181        // spacing), then releases the lock and `sleep_until`s its assigned
182        // slot. With concurrency=N and rate=D, N tasks can be sleeping
183        // simultaneously on N distinct deadlines, and execution begins at
184        // each task's deadline — so global dispatch rate is 1/D while up
185        // to N tasks run their operation concurrently.
186        let limiter = if self.rate_limit_delay.is_zero() {
187            None
188        } else {
189            Some(Arc::new(Mutex::new(None::<TokioInstant>)))
190        };
191        let rate_limit_delay = self.rate_limit_delay;
192
193        let results: Vec<BulkResult> = stream::iter(operations)
194            .map(|op| {
195                let completed = completed.clone();
196                let progress = progress.as_ref();
197                let on_result = on_result.as_ref();
198                let limiter = limiter.clone();
199                let whois_client = &self.whois_client;
200                let rdap_client = &self.rdap_client;
201                let dns_resolver = &self.dns_resolver;
202                let propagation_checker = &self.propagation_checker;
203                let smart_lookup = &self.smart_lookup;
204                let status_client = &self.status_client;
205                let availability_checker = &self.availability_checker;
206                let ssl_checker = &self.ssl_checker;
207
208                async move {
209                    // Rate-limited dispatch: claim our slot quickly under the
210                    // lock, then `sleep_until` outside the lock so multiple
211                    // tasks can be sleeping in parallel on their own slots.
212                    if let Some(limiter) = &limiter {
213                        let my_slot = {
214                            let mut next = limiter.lock().await;
215                            let now = TokioInstant::now();
216                            let slot = match *next {
217                                Some(prev) if prev > now => prev,
218                                _ => now,
219                            };
220                            *next = Some(slot + rate_limit_delay);
221                            slot
222                        };
223                        sleep_until(my_slot).await;
224                    }
225
226                    let start = std::time::Instant::now();
227                    let result = execute_operation(
228                        &op,
229                        &Clients {
230                            whois: whois_client,
231                            rdap: rdap_client,
232                            dns: dns_resolver,
233                            propagation: propagation_checker,
234                            lookup: smart_lookup,
235                            status: status_client,
236                            avail: availability_checker,
237                            ssl: ssl_checker,
238                        },
239                    )
240                    .await;
241                    let duration_ms = start.elapsed().as_millis() as u64;
242
243                    let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
244
245                    if let Some(progress) = progress {
246                        let desc = match &op {
247                            BulkOperation::Whois { domain }
248                            | BulkOperation::Rdap { domain }
249                            | BulkOperation::Dns { domain, .. }
250                            | BulkOperation::Propagation { domain, .. }
251                            | BulkOperation::Lookup { domain }
252                            | BulkOperation::Status { domain }
253                            | BulkOperation::Avail { domain }
254                            | BulkOperation::Info { domain }
255                            | BulkOperation::Ssl { domain } => domain.as_str(),
256                        };
257                        progress(count, total, desc);
258                    }
259
260                    let bulk_result = match result {
261                        Ok(data) => BulkResult {
262                            operation: op,
263                            success: true,
264                            data: Some(data),
265                            error: None,
266                            duration_ms,
267                        },
268                        Err(e) => {
269                            debug!(error = %e, "Bulk operation failed");
270                            BulkResult {
271                                operation: op,
272                                success: false,
273                                data: None,
274                                // Store the sanitized form for external return;
275                                // full detail stays in the debug log above.
276                                error: Some(e.sanitized_message()),
277                                duration_ms,
278                            }
279                        }
280                    };
281
282                    // Stream the row to any subscriber before it is folded into
283                    // the collected vec, so a UI sees results as they finish.
284                    if let Some(on_result) = on_result {
285                        on_result(&bulk_result);
286                    }
287
288                    bulk_result
289                }
290            })
291            // CONTRACT (#61): results come back in COMPLETION order, not input
292            // order (`buffer_unordered`). Consumers must re-key by
293            // `BulkResult.operation` rather than rely on positional index; do
294            // not assume `results[i]` corresponds to `operations[i]`.
295            .buffer_unordered(self.concurrency)
296            .collect()
297            .await;
298
299        let succeeded = results.iter().filter(|r| r.success).count();
300        let failed = results.iter().filter(|r| !r.success).count();
301        info!(
302            total = total,
303            succeeded = succeeded,
304            failed = failed,
305            "bulk operation completed"
306        );
307
308        results
309    }
310
311    pub async fn execute_whois(&self, domains: Vec<String>) -> Vec<BulkResult> {
312        let operations = domains
313            .into_iter()
314            .map(|domain| BulkOperation::Whois { domain })
315            .collect();
316        self.execute(operations, None).await
317    }
318
319    pub async fn execute_rdap(&self, domains: Vec<String>) -> Vec<BulkResult> {
320        let operations = domains
321            .into_iter()
322            .map(|domain| BulkOperation::Rdap { domain })
323            .collect();
324        self.execute(operations, None).await
325    }
326
327    pub async fn execute_dns(
328        &self,
329        domains: Vec<String>,
330        record_type: RecordType,
331    ) -> Vec<BulkResult> {
332        let operations = domains
333            .into_iter()
334            .map(|domain| BulkOperation::Dns {
335                domain,
336                record_type,
337            })
338            .collect();
339        self.execute(operations, None).await
340    }
341
342    pub async fn execute_propagation(
343        &self,
344        domains: Vec<String>,
345        record_type: RecordType,
346    ) -> Vec<BulkResult> {
347        let operations = domains
348            .into_iter()
349            .map(|domain| BulkOperation::Propagation {
350                domain,
351                record_type,
352            })
353            .collect();
354        self.execute(operations, None).await
355    }
356
357    pub async fn execute_lookup(&self, domains: Vec<String>) -> Vec<BulkResult> {
358        let operations = domains
359            .into_iter()
360            .map(|domain| BulkOperation::Lookup { domain })
361            .collect();
362        self.execute(operations, None).await
363    }
364
365    pub async fn execute_status(&self, domains: Vec<String>) -> Vec<BulkResult> {
366        let operations = domains
367            .into_iter()
368            .map(|domain| BulkOperation::Status { domain })
369            .collect();
370        self.execute(operations, None).await
371    }
372
373    pub async fn execute_avail(&self, domains: Vec<String>) -> Vec<BulkResult> {
374        let operations = domains
375            .into_iter()
376            .map(|domain| BulkOperation::Avail { domain })
377            .collect();
378        self.execute(operations, None).await
379    }
380
381    pub async fn execute_info(&self, domains: Vec<String>) -> Vec<BulkResult> {
382        let operations = domains
383            .into_iter()
384            .map(|domain| BulkOperation::Info { domain })
385            .collect();
386        self.execute(operations, None).await
387    }
388
389    pub async fn execute_ssl(&self, domains: Vec<String>) -> Vec<BulkResult> {
390        let operations = domains
391            .into_iter()
392            .map(|domain| BulkOperation::Ssl { domain })
393            .collect();
394        self.execute(operations, None).await
395    }
396}
397
398struct Clients<'a> {
399    whois: &'a WhoisClient,
400    rdap: &'a RdapClient,
401    dns: &'a DnsResolver,
402    propagation: &'a PropagationChecker,
403    lookup: &'a SmartLookup,
404    status: &'a StatusClient,
405    avail: &'a AvailabilityChecker,
406    ssl: &'a SslChecker,
407}
408
409async fn execute_operation(op: &BulkOperation, clients: &Clients<'_>) -> Result<BulkResultData> {
410    match op {
411        BulkOperation::Whois { domain } => {
412            let result = clients.whois.lookup(domain).await?;
413            Ok(BulkResultData::Whois(result))
414        }
415        BulkOperation::Rdap { domain } => {
416            let result = clients.rdap.lookup_domain(domain).await?;
417            Ok(BulkResultData::Rdap(Box::new(result)))
418        }
419        BulkOperation::Dns {
420            domain,
421            record_type,
422        } => {
423            let result = clients.dns.resolve(domain, *record_type, None).await?;
424            Ok(BulkResultData::Dns(result))
425        }
426        BulkOperation::Propagation {
427            domain,
428            record_type,
429        } => {
430            let result = clients.propagation.check(domain, *record_type).await?;
431            Ok(BulkResultData::Propagation(result))
432        }
433        BulkOperation::Lookup { domain } => {
434            let result = clients.lookup.lookup(domain).await?;
435            Ok(BulkResultData::Lookup(result))
436        }
437        BulkOperation::Status { domain } => {
438            let result = clients.status.check(domain).await?;
439            Ok(BulkResultData::Status(result))
440        }
441        BulkOperation::Avail { domain } => {
442            let result = clients.avail.check(domain).await?;
443            Ok(BulkResultData::Avail(result))
444        }
445        BulkOperation::Info { domain } => {
446            let result = clients.lookup.lookup(domain).await?;
447            Ok(BulkResultData::Info(
448                crate::domain_info::DomainInfo::from_lookup_result(&result),
449            ))
450        }
451        BulkOperation::Ssl { domain } => {
452            let result = clients.ssl.check(domain).await?;
453            Ok(BulkResultData::Ssl(result))
454        }
455    }
456}
457
458/// Keywords commonly used as CSV header labels for a domain column.
459const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
460
461/// Returns true if `first` looks like a CSV header row rather than a real domain.
462///
463/// Heuristic: take the first comma-delimited column and trim it. Real domains
464/// always contain a `.` (e.g., "example.com", "domain.com"); a bare keyword
465/// like "domain" or "hostname" does not. Only treat the row as a header when
466/// the first column has no dot AND matches a known header keyword.
467fn is_csv_header_row(first: &str) -> bool {
468    let first_col = first.split(',').next().unwrap_or(first).trim();
469    // Real domains always contain a dot; a bare keyword does not.
470    if first_col.contains('.') {
471        return false;
472    }
473    let label = first_col.to_lowercase();
474    HEADER_KEYWORDS.contains(&label.as_str())
475}
476
477pub fn parse_domains_from_file(content: &str) -> Vec<String> {
478    let mut domains: Vec<String> = content
479        .lines()
480        .map(|line| line.trim())
481        .filter(|line| !line.is_empty() && !line.starts_with('#'))
482        .map(|line| {
483            // Handle CSV format (take first column)
484            line.split(',').next().unwrap_or(line).trim().to_string()
485        })
486        .filter(|domain| domain.contains('.'))
487        .collect();
488
489    // A bare-keyword CSV header like "domain" / "hostname" has no dot and is
490    // already dropped by the filter above. `is_csv_header_row` additionally
491    // covers the edge case where the first surviving entry is a dotted header
492    // (rare, and the current heuristic treats such values as real domains —
493    // see `domain_dot_com_is_not_dropped_as_header`). The guard below is a
494    // belt-and-suspenders check that stays correct if the upstream filter
495    // ever changes.
496    if domains
497        .first()
498        .map(|d| is_csv_header_row(d))
499        .unwrap_or(false)
500    {
501        domains.remove(0);
502    }
503
504    domains
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_parse_domains_from_file() {
513        let content = r#"
514# This is a comment
515example1.com
516google2.com
517  whitespace3.com
518invalid
519csv,format,example.org
520"#;
521
522        let domains = parse_domains_from_file(content);
523        assert_eq!(domains.len(), 3);
524        assert!(domains.contains(&"example1.com".to_string()));
525        assert!(domains.contains(&"google2.com".to_string()));
526        assert!(domains.contains(&"whitespace3.com".to_string()));
527        // "invalid" and "csv" are filtered out because they don't contain a dot
528    }
529
530    #[test]
531    fn test_parse_domains_skip_bare_header() {
532        // Bare-keyword header ("domain") is dropped by the dot filter.
533        let content = "domain\nexample.com\n";
534        let domains = parse_domains_from_file(content);
535        assert_eq!(domains, vec!["example.com"]);
536    }
537
538    #[test]
539    fn test_parse_domains_multi_column_csv_header_dropped() {
540        // First column is a bare keyword → whole header row has no dot in col 1
541        // and is dropped by the dot filter.
542        let content = "domain,status\ngoogle.com,live\n";
543        let domains = parse_domains_from_file(content);
544        assert_eq!(domains, vec!["google.com"]);
545        assert!(!domains.contains(&"domain".to_string()));
546    }
547
548    #[test]
549    fn first_domain_with_no_digits_is_kept() {
550        // Regression: previous heuristic dropped the first entry when it
551        // had no ASCII digits, losing "google.com" / "amazon.com" / ...
552        let input = "google.com\namazon.com\n";
553        let result = parse_domains_from_file(input);
554        assert_eq!(result, vec!["google.com", "amazon.com"]);
555    }
556
557    #[test]
558    fn header_row_named_hostname_is_dropped() {
559        let input = "hostname\nexample.com\n";
560        let result = parse_domains_from_file(input);
561        assert_eq!(result, vec!["example.com"]);
562    }
563
564    #[test]
565    fn domain_dot_com_is_not_dropped_as_header() {
566        // "domain.com" is a real domain, not a header, because it has a dot.
567        let input = "domain.com\nexample.com\n";
568        let result = parse_domains_from_file(input);
569        assert_eq!(result, vec!["domain.com", "example.com"]);
570    }
571
572    #[test]
573    fn is_csv_header_row_detects_bare_keywords() {
574        assert!(is_csv_header_row("domain"));
575        assert!(is_csv_header_row("Hostname"));
576        assert!(is_csv_header_row("URL"));
577        assert!(is_csv_header_row("domain,status,notes"));
578        assert!(is_csv_header_row("  host  "));
579    }
580
581    #[test]
582    fn is_csv_header_row_rejects_dotted_values() {
583        assert!(!is_csv_header_row("domain.com"));
584        assert!(!is_csv_header_row("google.com"));
585        assert!(!is_csv_header_row("host.name"));
586    }
587
588    #[test]
589    fn is_csv_header_row_rejects_non_keyword() {
590        assert!(!is_csv_header_row("example"));
591        assert!(!is_csv_header_row("mydata"));
592    }
593
594    /// Regression test for the v0.26.7 rate-limiter regression. The
595    /// previous implementation held a `tokio::sync::Mutex` guard across
596    /// `Interval::tick().await`, fully serializing dispatch through the
597    /// lock. We need a behavioural assertion (not just code review) that
598    /// catches that pattern if it ever returns.
599    ///
600    /// With concurrency 5, rate_limit 50ms, and 4 hermetic-failure tasks:
601    /// - Dispatch should be spaced ~50ms apart (slot-claim semantics).
602    /// - All 4 tasks should *finish* in well under 4 * (per-op cost),
603    ///   i.e. they overlap on the execution side.
604    /// - Total wall time should be approximately the longest single op
605    ///   plus the cumulative slot delays (~150ms).
606    ///
607    /// The previous (broken) implementation would have produced wall
608    /// time = sum-of-per-op-cost because dispatch was serialized.
609    #[tokio::test]
610    async fn rate_limiter_dispatches_in_parallel_not_serialized() {
611        use std::time::Instant;
612        let executor = BulkExecutor::new()
613            .with_concurrency(5)
614            .with_rate_limit(Duration::from_millis(50));
615
616        // Use unresolvable `.invalid` hosts — they fail fast at DNS, well
617        // under the limiter's slot spacing. If dispatch IS rate-limited
618        // in parallel, all 4 finish in close to 3 * 50ms = 150ms (plus
619        // per-op DNS-fail cost, typically tens of ms). If dispatch was
620        // serialized through a lock, each task waits for the previous
621        // to FINISH before starting — total would be much higher.
622        let start = Instant::now();
623        let domains = vec![
624            "seer-rl-1.invalid".to_string(),
625            "seer-rl-2.invalid".to_string(),
626            "seer-rl-3.invalid".to_string(),
627            "seer-rl-4.invalid".to_string(),
628        ];
629        let results = executor.execute_ssl(domains).await;
630        let elapsed = start.elapsed();
631
632        assert_eq!(results.len(), 4);
633        // Generous upper bound — 2s is far above the worst legitimate
634        // wall time (4*50ms slots + 4*100ms DNS-fail = ~600ms) but well
635        // below the serialised path (would be 4 * per-op-cost minimum).
636        assert!(
637            elapsed < Duration::from_secs(2),
638            "rate-limited dispatch should run in parallel; took {:?}",
639            elapsed
640        );
641    }
642
643    #[tokio::test]
644    async fn execute_ssl_failure_path_for_unresolvable_host() {
645        // Verifies the SSL bulk arm wires correctly: an unresolvable hostname
646        // must surface as success=false with a non-empty error string. Uses
647        // the IETF-reserved `.invalid` TLD so this is hermetic.
648        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
649        let results = executor
650            .execute_ssl(vec!["seer-bulk-ssl-test.invalid".to_string()])
651            .await;
652        assert_eq!(results.len(), 1);
653        let r = &results[0];
654        assert!(!r.success, "expected failure, got success");
655        assert!(r.data.is_none(), "expected no data on failure");
656        let err = r.error.as_deref().unwrap_or("");
657        assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
658        assert!(
659            matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
660            "expected Ssl variant, got {:?}",
661            r.operation
662        );
663    }
664
665    #[tokio::test]
666    async fn execute_streaming_invokes_callback_per_result() {
667        use std::sync::atomic::AtomicUsize;
668        // Hermetic: `.invalid` hosts fail fast at DNS. We only assert the
669        // streaming contract — one callback per operation, and the callback
670        // count equals the returned vec length.
671        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
672        let seen = Arc::new(AtomicUsize::new(0));
673        let seen_cb = seen.clone();
674        let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
675            seen_cb.fetch_add(1, Ordering::Relaxed);
676        });
677        let operations = vec![
678            BulkOperation::Avail {
679                domain: "seer-stream-1.invalid".to_string(),
680            },
681            BulkOperation::Avail {
682                domain: "seer-stream-2.invalid".to_string(),
683            },
684            BulkOperation::Avail {
685                domain: "seer-stream-3.invalid".to_string(),
686            },
687        ];
688        let results = executor.execute_streaming(operations, cb).await;
689        assert_eq!(results.len(), 3, "all operations should be returned");
690        assert_eq!(
691            seen.load(Ordering::Relaxed),
692            3,
693            "callback must fire exactly once per result"
694        );
695    }
696
697    #[tokio::test]
698    #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
699    async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
700        let executor = BulkExecutor::new();
701        let results = executor
702            .execute_ssl(vec!["cloudflare.com".to_string()])
703            .await;
704        assert_eq!(results.len(), 1);
705        let r = &results[0];
706        assert!(r.success, "expected success, got error: {:?}", r.error);
707        let Some(BulkResultData::Ssl(ref report)) = r.data else {
708            panic!("expected Ssl data, got {:?}", r.data);
709        };
710        assert!(!report.chain.is_empty(), "chain must not be empty");
711        assert!(report.is_valid, "cloudflare leaf cert should be valid");
712        assert!(
713            report.days_until_expiry > 0,
714            "cert should still be valid in the future, got {} days",
715            report.days_until_expiry
716        );
717        assert!(
718            !report.san_names.is_empty(),
719            "cloudflare cert should have SAN entries"
720        );
721    }
722}