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