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