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::caa::CaaPolicy;
13use crate::confusables::ConfusableReport;
14use crate::dns::{DnsRecord, DnsResolver, PropagationChecker, PropagationResult, RecordType};
15use crate::error::Result;
16use crate::lookup::{LookupResult, SmartLookup};
17use crate::posture::EmailPosture;
18use crate::rdap::{RdapClient, RdapResponse};
19use crate::ssl::{SslChecker, SslReport};
20use crate::status::{StatusClient, StatusResponse};
21use crate::whois::{WhoisClient, WhoisResponse};
22
23pub type ProgressCallback = Box<dyn Fn(usize, usize, &str) + Send + Sync>;
24
25/// Callback invoked with each [`BulkResult`] the moment it completes.
26///
27/// Results are delivered in completion order (matching the return-vec ordering
28/// contract on [`BulkExecutor::execute`]), so a UI can stream rows as they
29/// finish instead of waiting for the whole batch. The full vec is still
30/// returned for callers that want it.
31pub type ResultCallback = Box<dyn Fn(&BulkResult) + Send + Sync>;
32
33/// A single operation to perform in a bulk execution batch.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum BulkOperation {
37    Whois {
38        domain: String,
39    },
40    Rdap {
41        domain: String,
42    },
43    Dns {
44        domain: String,
45        record_type: RecordType,
46    },
47    Propagation {
48        domain: String,
49        record_type: RecordType,
50    },
51    Lookup {
52        domain: String,
53    },
54    Status {
55        domain: String,
56    },
57    Avail {
58        domain: String,
59    },
60    Info {
61        domain: String,
62    },
63    Ssl {
64        domain: String,
65    },
66    /// Email/DNS security posture (SPF, DMARC, MTA-STS, BIMI, DANE).
67    Posture {
68        domain: String,
69    },
70    /// Registered look-alike (confusable) scan.
71    ///
72    /// EXPENSIVE relative to the other operations: each domain fans out its
73    /// own candidate generation, a DNS presence pre-filter over every
74    /// candidate, and a full smart lookup per surviving candidate. A batch of
75    /// N domains therefore multiplies into N independent candidate scans —
76    /// budget bulk sizes accordingly. Dispatch is throttled only by the
77    /// executor's existing semaphore/rate limiter (deliberately no extra
78    /// per-op limiting).
79    Confusables {
80        domain: String,
81    },
82    /// CAA (Certification Authority Authorization) policy lookup.
83    Caa {
84        domain: String,
85    },
86}
87
88impl BulkOperation {
89    /// Returns the domain this operation targets, regardless of variant.
90    pub fn domain(&self) -> &str {
91        match self {
92            Self::Whois { domain }
93            | Self::Rdap { domain }
94            | Self::Dns { domain, .. }
95            | Self::Propagation { domain, .. }
96            | Self::Lookup { domain }
97            | Self::Status { domain }
98            | Self::Avail { domain }
99            | Self::Info { domain }
100            | Self::Ssl { domain }
101            | Self::Posture { domain }
102            | Self::Confusables { domain }
103            | Self::Caa { domain } => domain,
104        }
105    }
106}
107
108/// The data returned from a bulk operation (varies by operation type).
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(tag = "result_type", content = "data", rename_all = "snake_case")]
111pub enum BulkResultData {
112    Whois(WhoisResponse),
113    Rdap(Box<RdapResponse>),
114    Dns(Vec<DnsRecord>),
115    Propagation(PropagationResult),
116    Lookup(LookupResult),
117    Status(StatusResponse),
118    Avail(AvailabilityResult),
119    Info(crate::domain_info::DomainInfo),
120    Ssl(SslReport),
121    Posture(EmailPosture),
122    Confusables(ConfusableReport),
123    Caa(CaaPolicy),
124}
125
126/// Result of a single operation within a bulk execution.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct BulkResult {
129    pub operation: BulkOperation,
130    pub success: bool,
131    pub data: Option<BulkResultData>,
132    pub error: Option<String>,
133    pub duration_ms: u64,
134}
135
136/// Executes bulk operations concurrently with rate limiting.
137#[derive(Debug, Clone)]
138pub struct BulkExecutor {
139    concurrency: usize,
140    rate_limit_delay: Duration,
141    whois_client: WhoisClient,
142    rdap_client: RdapClient,
143    dns_resolver: DnsResolver,
144    propagation_checker: PropagationChecker,
145    smart_lookup: SmartLookup,
146    status_client: StatusClient,
147    availability_checker: AvailabilityChecker,
148    ssl_checker: SslChecker,
149    /// Upstream nameserver for DNS operations (`None` = the resolver
150    /// default), so `bulk dns` honors the config's `nameserver` like `dig`.
151    nameserver: Option<String>,
152}
153
154impl Default for BulkExecutor {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl BulkExecutor {
161    pub fn new() -> Self {
162        Self {
163            concurrency: 10,
164            rate_limit_delay: Duration::from_millis(100),
165            whois_client: WhoisClient::new(),
166            rdap_client: RdapClient::new(),
167            dns_resolver: DnsResolver::new(),
168            propagation_checker: PropagationChecker::new(),
169            smart_lookup: SmartLookup::new(),
170            status_client: StatusClient::new(),
171            availability_checker: AvailabilityChecker::new(),
172            ssl_checker: SslChecker::new(),
173            nameserver: None,
174        }
175    }
176
177    /// Builds an executor honoring `~/.seer/config.toml` settings.
178    ///
179    /// Reads `bulk.concurrency` (clamped to 1–50) and `bulk.rate_limit_ms`
180    /// for dispatch pacing, plus every per-protocol timeout
181    /// (`timeouts.whois_secs` / `rdap_secs` / `dns_secs` / `http_secs`) for
182    /// the internal sub-clients. The propagation checker deliberately keeps
183    /// its own tuned timeouts (30-server fan-out has different latency
184    /// characteristics than a single query).
185    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
186        let mut executor = Self::new()
187            .with_concurrency(config.bulk.concurrency)
188            .with_rate_limit(config.bulk_rate_limit());
189        executor.whois_client = WhoisClient::from_config(config);
190        executor.rdap_client = RdapClient::from_config(config);
191        executor.dns_resolver = DnsResolver::from_config(config);
192        executor.smart_lookup = SmartLookup::from_config(config);
193        executor.status_client = StatusClient::from_config(config);
194        executor.availability_checker = AvailabilityChecker::from_config(config);
195        executor.ssl_checker = SslChecker::from_config(config);
196        executor.nameserver = config.nameserver.clone();
197        executor
198    }
199
200    pub fn with_concurrency(mut self, concurrency: usize) -> Self {
201        self.concurrency = concurrency.clamp(1, 50);
202        self
203    }
204
205    pub fn with_rate_limit(mut self, delay: Duration) -> Self {
206        self.rate_limit_delay = delay;
207        self
208    }
209
210    #[instrument(skip(self, operations, progress), fields(count = operations.len(), concurrency = self.concurrency))]
211    pub async fn execute(
212        &self,
213        operations: Vec<BulkOperation>,
214        progress: Option<ProgressCallback>,
215    ) -> Vec<BulkResult> {
216        self.execute_inner(operations, progress, None).await
217    }
218
219    /// Like [`execute`](Self::execute), but also invokes `on_result` with each
220    /// [`BulkResult`] as soon as it completes (completion order). Enables live
221    /// streaming of rows to a UI while the batch is still running.
222    pub async fn execute_streaming(
223        &self,
224        operations: Vec<BulkOperation>,
225        on_result: ResultCallback,
226    ) -> Vec<BulkResult> {
227        self.execute_inner(operations, None, Some(on_result)).await
228    }
229
230    #[instrument(skip(self, operations, progress, on_result), fields(count = operations.len(), concurrency = self.concurrency))]
231    async fn execute_inner(
232        &self,
233        operations: Vec<BulkOperation>,
234        progress: Option<ProgressCallback>,
235        on_result: Option<ResultCallback>,
236    ) -> Vec<BulkResult> {
237        let total = operations.len();
238        let completed = Arc::new(AtomicUsize::new(0));
239
240        info!("bulk operation started");
241        debug!(
242            total = total,
243            concurrency = self.concurrency,
244            "Starting bulk execution"
245        );
246
247        // Per-batch rate limiter. The naive `interval(...).tick().await` inside
248        // a `Mutex` would hold the lock across the await, serializing all
249        // dispatch through the lock at 1 / rate_limit_delay — exactly the
250        // regression v0.26.7 tried to fix from the other direction.
251        //
252        // Instead each task locks briefly to *claim* its dispatch slot (the
253        // next monotonically-increasing deadline at `rate_limit_delay`
254        // spacing), then releases the lock and `sleep_until`s its assigned
255        // slot. With concurrency=N and rate=D, N tasks can be sleeping
256        // simultaneously on N distinct deadlines, and execution begins at
257        // each task's deadline — so global dispatch rate is 1/D while up
258        // to N tasks run their operation concurrently.
259        let limiter = if self.rate_limit_delay.is_zero() {
260            None
261        } else {
262            Some(Arc::new(SlotLimiter::new(self.rate_limit_delay)))
263        };
264
265        let results: Vec<BulkResult> = stream::iter(operations)
266            .map(|op| {
267                let completed = completed.clone();
268                let progress = progress.as_ref();
269                let on_result = on_result.as_ref();
270                let limiter = limiter.clone();
271                let whois_client = &self.whois_client;
272                let rdap_client = &self.rdap_client;
273                let dns_resolver = &self.dns_resolver;
274                let propagation_checker = &self.propagation_checker;
275                let smart_lookup = &self.smart_lookup;
276                let status_client = &self.status_client;
277                let availability_checker = &self.availability_checker;
278                let ssl_checker = &self.ssl_checker;
279                let nameserver = self.nameserver.as_deref();
280                let confusables_concurrency = self.concurrency;
281
282                async move {
283                    // Rate-limited dispatch: claim our slot quickly under the
284                    // lock, then `sleep_until` outside the lock so multiple
285                    // tasks can be sleeping in parallel on their own slots.
286                    if let Some(limiter) = &limiter {
287                        limiter.wait().await;
288                    }
289
290                    let start = std::time::Instant::now();
291                    let result = execute_operation(
292                        &op,
293                        &Clients {
294                            whois: whois_client,
295                            rdap: rdap_client,
296                            dns: dns_resolver,
297                            propagation: propagation_checker,
298                            lookup: smart_lookup,
299                            status: status_client,
300                            avail: availability_checker,
301                            ssl: ssl_checker,
302                            nameserver,
303                            confusables_concurrency,
304                        },
305                    )
306                    .await;
307                    let duration_ms = start.elapsed().as_millis() as u64;
308
309                    let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
310
311                    if let Some(progress) = progress {
312                        progress(count, total, op.domain());
313                    }
314
315                    let bulk_result = match result {
316                        Ok(data) => BulkResult {
317                            operation: op,
318                            success: true,
319                            data: Some(trim_result_data(data)),
320                            error: None,
321                            duration_ms,
322                        },
323                        Err(e) => {
324                            debug!(error = %e, "Bulk operation failed");
325                            BulkResult {
326                                operation: op,
327                                success: false,
328                                data: None,
329                                // Store the sanitized form for external return;
330                                // full detail stays in the debug log above.
331                                error: Some(e.sanitized_message()),
332                                duration_ms,
333                            }
334                        }
335                    };
336
337                    // Stream the row to any subscriber before it is folded into
338                    // the collected vec, so a UI sees results as they finish.
339                    if let Some(on_result) = on_result {
340                        on_result(&bulk_result);
341                    }
342
343                    bulk_result
344                }
345            })
346            // CONTRACT (#61): results come back in COMPLETION order, not input
347            // order (`buffer_unordered`). Consumers must re-key by
348            // `BulkResult.operation` rather than rely on positional index; do
349            // not assume `results[i]` corresponds to `operations[i]`.
350            .buffer_unordered(self.concurrency)
351            .collect()
352            .await;
353
354        let succeeded = results.iter().filter(|r| r.success).count();
355        let failed = results.iter().filter(|r| !r.success).count();
356        info!(
357            total = total,
358            succeeded = succeeded,
359            failed = failed,
360            "bulk operation completed"
361        );
362
363        results
364    }
365
366    pub async fn execute_whois(&self, domains: Vec<String>) -> Vec<BulkResult> {
367        let operations = domains
368            .into_iter()
369            .map(|domain| BulkOperation::Whois { domain })
370            .collect();
371        self.execute(operations, None).await
372    }
373
374    pub async fn execute_rdap(&self, domains: Vec<String>) -> Vec<BulkResult> {
375        let operations = domains
376            .into_iter()
377            .map(|domain| BulkOperation::Rdap { domain })
378            .collect();
379        self.execute(operations, None).await
380    }
381
382    pub async fn execute_dns(
383        &self,
384        domains: Vec<String>,
385        record_type: RecordType,
386    ) -> Vec<BulkResult> {
387        let operations = domains
388            .into_iter()
389            .map(|domain| BulkOperation::Dns {
390                domain,
391                record_type,
392            })
393            .collect();
394        self.execute(operations, None).await
395    }
396
397    pub async fn execute_propagation(
398        &self,
399        domains: Vec<String>,
400        record_type: RecordType,
401    ) -> Vec<BulkResult> {
402        let operations = domains
403            .into_iter()
404            .map(|domain| BulkOperation::Propagation {
405                domain,
406                record_type,
407            })
408            .collect();
409        self.execute(operations, None).await
410    }
411
412    pub async fn execute_lookup(&self, domains: Vec<String>) -> Vec<BulkResult> {
413        let operations = domains
414            .into_iter()
415            .map(|domain| BulkOperation::Lookup { domain })
416            .collect();
417        self.execute(operations, None).await
418    }
419
420    pub async fn execute_status(&self, domains: Vec<String>) -> Vec<BulkResult> {
421        let operations = domains
422            .into_iter()
423            .map(|domain| BulkOperation::Status { domain })
424            .collect();
425        self.execute(operations, None).await
426    }
427
428    pub async fn execute_avail(&self, domains: Vec<String>) -> Vec<BulkResult> {
429        let operations = domains
430            .into_iter()
431            .map(|domain| BulkOperation::Avail { domain })
432            .collect();
433        self.execute(operations, None).await
434    }
435
436    pub async fn execute_info(&self, domains: Vec<String>) -> Vec<BulkResult> {
437        let operations = domains
438            .into_iter()
439            .map(|domain| BulkOperation::Info { domain })
440            .collect();
441        self.execute(operations, None).await
442    }
443
444    pub async fn execute_ssl(&self, domains: Vec<String>) -> Vec<BulkResult> {
445        let operations = domains
446            .into_iter()
447            .map(|domain| BulkOperation::Ssl { domain })
448            .collect();
449        self.execute(operations, None).await
450    }
451
452    pub async fn execute_posture(&self, domains: Vec<String>) -> Vec<BulkResult> {
453        let operations = domains
454            .into_iter()
455            .map(|domain| BulkOperation::Posture { domain })
456            .collect();
457        self.execute(operations, None).await
458    }
459
460    /// Bulk look-alike scan. See [`BulkOperation::Confusables`] for the cost
461    /// caveat — each domain fans out its own candidate scan.
462    pub async fn execute_confusables(&self, domains: Vec<String>) -> Vec<BulkResult> {
463        let operations = domains
464            .into_iter()
465            .map(|domain| BulkOperation::Confusables { domain })
466            .collect();
467        self.execute(operations, None).await
468    }
469
470    pub async fn execute_caa(&self, domains: Vec<String>) -> Vec<BulkResult> {
471        let operations = domains
472            .into_iter()
473            .map(|domain| BulkOperation::Caa { domain })
474            .collect();
475        self.execute(operations, None).await
476    }
477}
478
479/// Trims retained raw WHOIS bodies before a result enters the buffered vec.
480///
481/// `WhoisResponse.raw_response` is `#[serde(skip_serializing)]`, so no bulk
482/// output path (JSON/YAML/CSV) ever emits it — the truncation is invisible
483/// to consumers. Without it, each Whois/Lookup result can retain up to 1 MB
484/// of raw body (the WHOIS client's response cap), which an adversarial batch
485/// could inflate to ~1 GB at the CLI's 1000-domain cap.
486fn trim_result_data(data: BulkResultData) -> BulkResultData {
487    match data {
488        BulkResultData::Whois(mut whois) => {
489            crate::lookup::trim_whois_raw(&mut whois);
490            BulkResultData::Whois(whois)
491        }
492        BulkResultData::Lookup(result) => {
493            BulkResultData::Lookup(crate::lookup::trim_raw_response(result))
494        }
495        other => other,
496    }
497}
498
499/// Spaces dispatches `delay` apart without serializing them.
500///
501/// Each caller locks only long enough to *claim* the next free slot (a
502/// monotonically increasing deadline), then sleeps until that slot with the
503/// lock released — so many callers can wait on distinct slots at once, and
504/// work that starts at a slot overlaps with later callers. Holding the lock
505/// across the wait (the v0.26.7 regression) would serialize everything.
506struct SlotLimiter {
507    delay: Duration,
508    next: Mutex<Option<TokioInstant>>,
509}
510
511impl SlotLimiter {
512    fn new(delay: Duration) -> Self {
513        Self {
514            delay,
515            next: Mutex::new(None),
516        }
517    }
518
519    /// Waits for this caller's dispatch slot.
520    async fn wait(&self) {
521        let my_slot = {
522            let mut next = self.next.lock().await;
523            let now = TokioInstant::now();
524            let slot = match *next {
525                Some(prev) if prev > now => prev,
526                _ => now,
527            };
528            *next = Some(slot + self.delay);
529            slot
530        };
531        sleep_until(my_slot).await;
532    }
533}
534
535struct Clients<'a> {
536    whois: &'a WhoisClient,
537    rdap: &'a RdapClient,
538    dns: &'a DnsResolver,
539    propagation: &'a PropagationChecker,
540    lookup: &'a SmartLookup,
541    status: &'a StatusClient,
542    avail: &'a AvailabilityChecker,
543    ssl: &'a SslChecker,
544    /// Configured upstream nameserver for plain DNS operations.
545    nameserver: Option<&'a str>,
546    /// Per-domain fan-out width for the confusables candidate scan
547    /// (mirrors the executor's own concurrency, like the CLI's single-domain
548    /// command uses `bulk.concurrency`).
549    confusables_concurrency: usize,
550}
551
552async fn execute_operation(op: &BulkOperation, clients: &Clients<'_>) -> Result<BulkResultData> {
553    match op {
554        BulkOperation::Whois { domain } => {
555            let result = clients.whois.lookup(domain).await?;
556            Ok(BulkResultData::Whois(result))
557        }
558        BulkOperation::Rdap { domain } => {
559            let result = clients.rdap.lookup_domain(domain).await?;
560            Ok(BulkResultData::Rdap(Box::new(result)))
561        }
562        BulkOperation::Dns {
563            domain,
564            record_type,
565        } => {
566            let result = clients
567                .dns
568                .resolve(domain, *record_type, clients.nameserver)
569                .await?;
570            Ok(BulkResultData::Dns(result))
571        }
572        BulkOperation::Propagation {
573            domain,
574            record_type,
575        } => {
576            let result = clients.propagation.check(domain, *record_type).await?;
577            Ok(BulkResultData::Propagation(result))
578        }
579        BulkOperation::Lookup { domain } => {
580            let result = clients.lookup.lookup(domain).await?;
581            Ok(BulkResultData::Lookup(result))
582        }
583        BulkOperation::Status { domain } => {
584            let result = clients.status.check(domain).await?;
585            Ok(BulkResultData::Status(result))
586        }
587        BulkOperation::Avail { domain } => {
588            let result = clients.avail.check(domain).await?;
589            Ok(BulkResultData::Avail(result))
590        }
591        BulkOperation::Info { domain } => {
592            let result = clients.lookup.lookup(domain).await?;
593            Ok(BulkResultData::Info(
594                crate::domain_info::DomainInfo::from_lookup_result(&result),
595            ))
596        }
597        BulkOperation::Ssl { domain } => {
598            let result = clients.ssl.check(domain).await?;
599            Ok(BulkResultData::Ssl(result))
600        }
601        BulkOperation::Posture { domain } => {
602            let result = crate::posture::lookup_email_posture(clients.dns, domain).await?;
603            Ok(BulkResultData::Posture(result))
604        }
605        BulkOperation::Confusables { domain } => {
606            let result = crate::confusables::find_confusables(
607                clients.lookup,
608                domain,
609                clients.confusables_concurrency,
610            )
611            .await?;
612            Ok(BulkResultData::Confusables(result))
613        }
614        BulkOperation::Caa { domain } => {
615            // lookup_caa itself never fails (CAA is advisory; resolver errors
616            // yield an empty policy) but expects a normalized domain — the
617            // same normalize-then-query shape the CLI's single-domain command
618            // uses, so an invalid domain still surfaces as a per-row error.
619            let domain = crate::validation::normalize_domain(domain)?;
620            let policy = crate::caa::lookup_caa(clients.dns, &domain).await;
621            Ok(BulkResultData::Caa(policy))
622        }
623    }
624}
625
626/// Keywords commonly used as CSV header labels for a domain column.
627const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
628
629/// Returns true if `first` looks like a CSV header row rather than a real domain.
630///
631/// Heuristic: take the first comma-delimited column and trim it. Real domains
632/// always contain a `.` (e.g., "example.com", "domain.com"); a bare keyword
633/// like "domain" or "hostname" does not. Only treat the row as a header when
634/// the first column has no dot AND matches a known header keyword.
635fn is_csv_header_row(first: &str) -> bool {
636    let first_col = first.split(',').next().unwrap_or(first).trim();
637    // Real domains always contain a dot; a bare keyword does not.
638    if first_col.contains('.') {
639        return false;
640    }
641    let label = first_col.to_lowercase();
642    HEADER_KEYWORDS.contains(&label.as_str())
643}
644
645pub fn parse_domains_from_file(content: &str) -> Vec<String> {
646    // A UTF-8 BOM (Excel's "CSV UTF-8" export) is not whitespace to
647    // `str::trim`: it would hide a first-line `#` comment and leak invisibly
648    // into the first domain.
649    let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
650    let mut domains: Vec<String> = content
651        .lines()
652        .map(|line| line.trim())
653        .filter(|line| !line.is_empty() && !line.starts_with('#'))
654        .map(|line| {
655            // Handle CSV format (take first column). Spreadsheet exports
656            // quote fields (`"example.com","Alice"`); the quotes are CSV
657            // syntax, not part of the domain.
658            line.split(',')
659                .next()
660                .unwrap_or(line)
661                .trim()
662                .trim_matches('"')
663                .trim()
664                .to_string()
665        })
666        .filter(|domain| domain.contains('.'))
667        .collect();
668
669    // A bare-keyword CSV header like "domain" / "hostname" has no dot and is
670    // already dropped by the filter above. `is_csv_header_row` additionally
671    // covers the edge case where the first surviving entry is a dotted header
672    // (rare, and the current heuristic treats such values as real domains —
673    // see `domain_dot_com_is_not_dropped_as_header`). The guard below is a
674    // belt-and-suspenders check that stays correct if the upstream filter
675    // ever changes.
676    if domains
677        .first()
678        .map(|d| is_csv_header_row(d))
679        .unwrap_or(false)
680    {
681        domains.remove(0);
682    }
683
684    domains
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn test_parse_domains_from_file() {
693        let content = r#"
694# This is a comment
695example1.com
696google2.com
697  whitespace3.com
698invalid
699csv,format,example.org
700"#;
701
702        let domains = parse_domains_from_file(content);
703        assert_eq!(domains.len(), 3);
704        assert!(domains.contains(&"example1.com".to_string()));
705        assert!(domains.contains(&"google2.com".to_string()));
706        assert!(domains.contains(&"whitespace3.com".to_string()));
707        // "invalid" and "csv" are filtered out because they don't contain a dot
708    }
709
710    #[test]
711    fn parse_domains_handles_bom_and_quoted_csv_fields() {
712        // Excel "CSV UTF-8" export: BOM, quoted fields, a comment first line.
713        let content =
714            "\u{FEFF}# domains for example.com\n\"example.com\",\"Alice\"\n\"test.org\"\n";
715        assert_eq!(
716            parse_domains_from_file(content),
717            vec!["example.com", "test.org"]
718        );
719        // A BOM directly before the first domain must not leak into it.
720        assert_eq!(
721            parse_domains_from_file("\u{FEFF}example.com\n"),
722            vec!["example.com"]
723        );
724    }
725
726    #[test]
727    fn from_config_carries_the_configured_nameserver() {
728        let config = crate::config::SeerConfig {
729            nameserver: Some("9.9.9.9".to_string()),
730            ..Default::default()
731        };
732        assert_eq!(
733            BulkExecutor::from_config(&config).nameserver.as_deref(),
734            Some("9.9.9.9")
735        );
736        assert!(BulkExecutor::new().nameserver.is_none());
737    }
738
739    #[test]
740    fn test_parse_domains_skip_bare_header() {
741        // Bare-keyword header ("domain") is dropped by the dot filter.
742        let content = "domain\nexample.com\n";
743        let domains = parse_domains_from_file(content);
744        assert_eq!(domains, vec!["example.com"]);
745    }
746
747    #[test]
748    fn test_parse_domains_multi_column_csv_header_dropped() {
749        // First column is a bare keyword → whole header row has no dot in col 1
750        // and is dropped by the dot filter.
751        let content = "domain,status\ngoogle.com,live\n";
752        let domains = parse_domains_from_file(content);
753        assert_eq!(domains, vec!["google.com"]);
754        assert!(!domains.contains(&"domain".to_string()));
755    }
756
757    #[test]
758    fn first_domain_with_no_digits_is_kept() {
759        // Regression: previous heuristic dropped the first entry when it
760        // had no ASCII digits, losing "google.com" / "amazon.com" / ...
761        let input = "google.com\namazon.com\n";
762        let result = parse_domains_from_file(input);
763        assert_eq!(result, vec!["google.com", "amazon.com"]);
764    }
765
766    #[test]
767    fn header_row_named_hostname_is_dropped() {
768        let input = "hostname\nexample.com\n";
769        let result = parse_domains_from_file(input);
770        assert_eq!(result, vec!["example.com"]);
771    }
772
773    #[test]
774    fn domain_dot_com_is_not_dropped_as_header() {
775        // "domain.com" is a real domain, not a header, because it has a dot.
776        let input = "domain.com\nexample.com\n";
777        let result = parse_domains_from_file(input);
778        assert_eq!(result, vec!["domain.com", "example.com"]);
779    }
780
781    #[test]
782    fn is_csv_header_row_detects_bare_keywords() {
783        assert!(is_csv_header_row("domain"));
784        assert!(is_csv_header_row("Hostname"));
785        assert!(is_csv_header_row("URL"));
786        assert!(is_csv_header_row("domain,status,notes"));
787        assert!(is_csv_header_row("  host  "));
788    }
789
790    #[test]
791    fn is_csv_header_row_rejects_dotted_values() {
792        assert!(!is_csv_header_row("domain.com"));
793        assert!(!is_csv_header_row("google.com"));
794        assert!(!is_csv_header_row("host.name"));
795    }
796
797    #[test]
798    fn is_csv_header_row_rejects_non_keyword() {
799        assert!(!is_csv_header_row("example"));
800        assert!(!is_csv_header_row("mydata"));
801    }
802
803    #[test]
804    fn bulk_operation_domain_returns_domain_for_every_variant() {
805        let d = "example.com".to_string();
806        let ops = vec![
807            BulkOperation::Whois { domain: d.clone() },
808            BulkOperation::Rdap { domain: d.clone() },
809            BulkOperation::Dns {
810                domain: d.clone(),
811                record_type: RecordType::A,
812            },
813            BulkOperation::Propagation {
814                domain: d.clone(),
815                record_type: RecordType::A,
816            },
817            BulkOperation::Lookup { domain: d.clone() },
818            BulkOperation::Status { domain: d.clone() },
819            BulkOperation::Avail { domain: d.clone() },
820            BulkOperation::Info { domain: d.clone() },
821            BulkOperation::Ssl { domain: d.clone() },
822            BulkOperation::Posture { domain: d.clone() },
823            BulkOperation::Confusables { domain: d.clone() },
824            BulkOperation::Caa { domain: d.clone() },
825        ];
826        for op in &ops {
827            assert_eq!(op.domain(), "example.com", "variant {:?}", op);
828        }
829    }
830
831    /// The serde tags on the new variants are part of the public bulk API
832    /// (REST/MCP consumers match on `type` / `result_type`); pin them.
833    #[test]
834    fn new_bulk_variants_serialize_with_snake_case_tags() {
835        let op = BulkOperation::Posture {
836            domain: "example.com".to_string(),
837        };
838        let json = serde_json::to_string(&op).expect("serialize Posture op");
839        assert!(json.contains(r#""type":"posture""#), "got: {json}");
840
841        let op = BulkOperation::Confusables {
842            domain: "example.com".to_string(),
843        };
844        let json = serde_json::to_string(&op).expect("serialize Confusables op");
845        assert!(json.contains(r#""type":"confusables""#), "got: {json}");
846
847        let op = BulkOperation::Caa {
848            domain: "example.com".to_string(),
849        };
850        let json = serde_json::to_string(&op).expect("serialize Caa op");
851        assert!(json.contains(r#""type":"caa""#), "got: {json}");
852
853        let data = BulkResultData::Caa(CaaPolicy::empty());
854        let json = serde_json::to_string(&data).expect("serialize Caa result");
855        assert!(json.contains(r#""result_type":"caa""#), "got: {json}");
856    }
857
858    /// The three new arms must surface an invalid domain as a per-row failure
859    /// (success=false, sanitized error) instead of panicking or hanging.
860    /// Fully offline: normalization rejects the input before any I/O.
861    #[tokio::test]
862    async fn new_bulk_arms_fail_cleanly_on_invalid_domain() {
863        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
864        let bad = "not a domain".to_string();
865
866        for (name, results) in [
867            ("posture", executor.execute_posture(vec![bad.clone()]).await),
868            (
869                "confusables",
870                executor.execute_confusables(vec![bad.clone()]).await,
871            ),
872            ("caa", executor.execute_caa(vec![bad.clone()]).await),
873        ] {
874            assert_eq!(results.len(), 1, "{name}: expected one result");
875            let r = &results[0];
876            assert!(!r.success, "{name}: expected failure for invalid domain");
877            assert!(r.data.is_none(), "{name}: expected no data on failure");
878            let err = r.error.as_deref().unwrap_or("");
879            assert!(!err.is_empty(), "{name}: expected non-empty error");
880            assert_eq!(r.operation.domain(), "not a domain", "{name}");
881        }
882    }
883
884    /// `from_config` must apply bulk pacing settings and clamp concurrency
885    /// through the same builder path as manual construction.
886    #[test]
887    fn from_config_applies_bulk_settings() {
888        let mut config = crate::config::SeerConfig::default();
889        config.bulk.concurrency = 25;
890        config.bulk.rate_limit_ms = 250;
891
892        let executor = BulkExecutor::from_config(&config);
893        assert_eq!(executor.concurrency, 25);
894        assert_eq!(executor.rate_limit_delay, Duration::from_millis(250));
895
896        // Out-of-range concurrency is clamped by the builder (defense in
897        // depth on top of SeerConfig::load's own clamping).
898        config.bulk.concurrency = 500;
899        let executor = BulkExecutor::from_config(&config);
900        assert_eq!(executor.concurrency, 50);
901    }
902
903    /// An oversized WHOIS raw_response must come out truncated when the
904    /// result enters the buffer (`trim_result_data` runs on every Ok result
905    /// in `execute_inner`). 32 KB cap + marker — see lookup::trim_whois_raw.
906    #[test]
907    fn bulk_whois_oversized_raw_response_is_truncated() {
908        const MAX_RAW: usize = 32 * 1024;
909        let raw = "a".repeat(MAX_RAW + 1000);
910        let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
911        assert!(whois.raw_response.len() > MAX_RAW);
912
913        let trimmed = trim_result_data(BulkResultData::Whois(whois));
914        let BulkResultData::Whois(whois) = trimmed else {
915            panic!("expected Whois variant");
916        };
917        assert!(
918            whois.raw_response.len() <= MAX_RAW + "\n... [truncated]".len(),
919            "raw_response not bounded: {} bytes",
920            whois.raw_response.len()
921        );
922        assert!(whois.raw_response.ends_with("[truncated]"));
923    }
924
925    #[test]
926    fn bulk_lookup_oversized_raw_response_is_truncated() {
927        const MAX_RAW: usize = 32 * 1024;
928        let raw = "a".repeat(MAX_RAW + 1000);
929        let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
930
931        let trimmed = trim_result_data(BulkResultData::Lookup(LookupResult::Whois {
932            data: whois,
933            rdap_error: None,
934            rdap_fallback: None,
935        }));
936        let BulkResultData::Lookup(LookupResult::Whois { data, .. }) = trimmed else {
937            panic!("expected Lookup(Whois) variant");
938        };
939        assert!(data.raw_response.len() <= MAX_RAW + "\n... [truncated]".len());
940        assert!(data.raw_response.ends_with("[truncated]"));
941    }
942
943    /// Regression test for the v0.26.7 rate-limiter regression, which held
944    /// the lock across the wait and so serialized dispatch behind it.
945    ///
946    /// Runs on tokio's paused clock with a controlled 500ms "operation" after
947    /// each slot, so the timing is exact rather than depending on how fast a
948    /// real lookup happens to fail: 4 callers at 50ms spacing must finish at
949    /// 150ms (last slot) + 500ms (its op) = 650ms. Any serialization of the
950    /// callers pushes that to at least 4 × 500ms.
951    #[tokio::test(start_paused = true)]
952    async fn rate_limiter_dispatches_in_parallel_not_serialized() {
953        let limiter = Arc::new(SlotLimiter::new(Duration::from_millis(50)));
954        let start = TokioInstant::now();
955        let slots: Vec<Duration> = stream::iter(0..4)
956            .map(|_| {
957                let limiter = limiter.clone();
958                async move {
959                    limiter.wait().await;
960                    let slot = start.elapsed();
961                    tokio::time::sleep(Duration::from_millis(500)).await;
962                    slot
963                }
964            })
965            .buffer_unordered(4)
966            .collect()
967            .await;
968        let elapsed = start.elapsed();
969
970        let mut slots = slots;
971        slots.sort();
972        assert_eq!(
973            slots,
974            [0, 50, 100, 150].map(Duration::from_millis),
975            "dispatches must be spaced exactly one delay apart"
976        );
977        assert_eq!(
978            elapsed,
979            Duration::from_millis(650),
980            "slow operations must overlap, not run one after another"
981        );
982    }
983
984    #[tokio::test]
985    async fn execute_ssl_failure_path_for_unresolvable_host() {
986        // Verifies the SSL bulk arm wires correctly: an unresolvable hostname
987        // must surface as success=false with a non-empty error string. Uses
988        // the IETF-reserved `.invalid` TLD so this is hermetic.
989        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
990        let results = executor
991            .execute_ssl(vec!["seer-bulk-ssl-test.invalid".to_string()])
992            .await;
993        assert_eq!(results.len(), 1);
994        let r = &results[0];
995        assert!(!r.success, "expected failure, got success");
996        assert!(r.data.is_none(), "expected no data on failure");
997        let err = r.error.as_deref().unwrap_or("");
998        assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
999        assert!(
1000            matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
1001            "expected Ssl variant, got {:?}",
1002            r.operation
1003        );
1004    }
1005
1006    #[tokio::test]
1007    async fn execute_streaming_invokes_callback_per_result() {
1008        use std::sync::atomic::AtomicUsize;
1009        // Hermetic: `.invalid` hosts fail fast at DNS. We only assert the
1010        // streaming contract — one callback per operation, and the callback
1011        // count equals the returned vec length.
1012        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
1013        let seen = Arc::new(AtomicUsize::new(0));
1014        let seen_cb = seen.clone();
1015        let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
1016            seen_cb.fetch_add(1, Ordering::Relaxed);
1017        });
1018        let operations = vec![
1019            BulkOperation::Avail {
1020                domain: "seer-stream-1.invalid".to_string(),
1021            },
1022            BulkOperation::Avail {
1023                domain: "seer-stream-2.invalid".to_string(),
1024            },
1025            BulkOperation::Avail {
1026                domain: "seer-stream-3.invalid".to_string(),
1027            },
1028        ];
1029        let results = executor.execute_streaming(operations, cb).await;
1030        assert_eq!(results.len(), 3, "all operations should be returned");
1031        assert_eq!(
1032            seen.load(Ordering::Relaxed),
1033            3,
1034            "callback must fire exactly once per result"
1035        );
1036    }
1037
1038    #[tokio::test]
1039    #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
1040    async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
1041        let executor = BulkExecutor::new();
1042        let results = executor
1043            .execute_ssl(vec!["cloudflare.com".to_string()])
1044            .await;
1045        assert_eq!(results.len(), 1);
1046        let r = &results[0];
1047        assert!(r.success, "expected success, got error: {:?}", r.error);
1048        let Some(BulkResultData::Ssl(ref report)) = r.data else {
1049            panic!("expected Ssl data, got {:?}", r.data);
1050        };
1051        assert!(!report.chain.is_empty(), "chain must not be empty");
1052        assert!(report.is_valid, "cloudflare leaf cert should be valid");
1053        assert!(
1054            report.days_until_expiry > 0,
1055            "cert should still be valid in the future, got {} days",
1056            report.days_until_expiry
1057        );
1058        assert!(
1059            !report.san_names.is_empty(),
1060            "cloudflare cert should have SAN entries"
1061        );
1062    }
1063}