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. The scans running at once share one
77    /// scan's fan-out (the executor's concurrency divided among them), so the
78    /// total in flight stays bounded instead of multiplying per row.
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 (a fan-out to every server in its list has
184    /// different latency 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        // Operations that can actually be in flight together.
266        let parallel = self.concurrency.min(total);
267
268        let results: Vec<BulkResult> = stream::iter(operations)
269            .map(|op| {
270                let completed = completed.clone();
271                let progress = progress.as_ref();
272                let on_result = on_result.as_ref();
273                let limiter = limiter.clone();
274
275                async move {
276                    // Rate-limited dispatch: claim our slot quickly under the
277                    // lock, then `sleep_until` outside the lock so multiple
278                    // tasks can be sleeping in parallel on their own slots.
279                    if let Some(limiter) = &limiter {
280                        limiter.wait().await;
281                    }
282
283                    let start = std::time::Instant::now();
284                    let result = self.run_op(&op, parallel).await;
285                    let duration_ms = start.elapsed().as_millis() as u64;
286
287                    let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
288
289                    if let Some(progress) = progress {
290                        progress(count, total, op.domain());
291                    }
292
293                    let bulk_result = match result {
294                        Ok(data) => BulkResult {
295                            operation: op,
296                            success: true,
297                            data: Some(trim_result_data(data)),
298                            error: None,
299                            duration_ms,
300                        },
301                        Err(e) => {
302                            debug!(error = %e, "Bulk operation failed");
303                            BulkResult {
304                                operation: op,
305                                success: false,
306                                data: None,
307                                // Store the sanitized form for external return;
308                                // full detail stays in the debug log above.
309                                error: Some(e.sanitized_message()),
310                                duration_ms,
311                            }
312                        }
313                    };
314
315                    // Stream the row to any subscriber before it is folded into
316                    // the collected vec, so a UI sees results as they finish.
317                    if let Some(on_result) = on_result {
318                        on_result(&bulk_result);
319                    }
320
321                    bulk_result
322                }
323            })
324            // CONTRACT (#61): results come back in COMPLETION order, not input
325            // order (`buffer_unordered`). Consumers must re-key by
326            // `BulkResult.operation` rather than rely on positional index; do
327            // not assume `results[i]` corresponds to `operations[i]`.
328            .buffer_unordered(self.concurrency)
329            .collect()
330            .await;
331
332        let succeeded = results.iter().filter(|r| r.success).count();
333        let failed = results.iter().filter(|r| !r.success).count();
334        info!(
335            total = total,
336            succeeded = succeeded,
337            failed = failed,
338            "bulk operation completed"
339        );
340
341        results
342    }
343
344    /// Dispatches one operation to the matching sub-client; `parallel` is how
345    /// many operations of the batch run at once.
346    async fn run_op(&self, op: &BulkOperation, parallel: usize) -> Result<BulkResultData> {
347        match op {
348            BulkOperation::Whois { domain } => {
349                let result = self.whois_client.lookup(domain).await?;
350                Ok(BulkResultData::Whois(result))
351            }
352            BulkOperation::Rdap { domain } => {
353                let result = self.rdap_client.lookup_domain(domain).await?;
354                Ok(BulkResultData::Rdap(Box::new(result)))
355            }
356            BulkOperation::Dns {
357                domain,
358                record_type,
359            } => {
360                let result = self
361                    .dns_resolver
362                    .resolve(domain, *record_type, self.nameserver.as_deref())
363                    .await?;
364                Ok(BulkResultData::Dns(result))
365            }
366            BulkOperation::Propagation {
367                domain,
368                record_type,
369            } => {
370                let result = self.propagation_checker.check(domain, *record_type).await?;
371                Ok(BulkResultData::Propagation(result))
372            }
373            BulkOperation::Lookup { domain } => {
374                let result = self.smart_lookup.lookup(domain).await?;
375                Ok(BulkResultData::Lookup(result))
376            }
377            BulkOperation::Status { domain } => {
378                let result = self.status_client.check(domain).await?;
379                Ok(BulkResultData::Status(result))
380            }
381            BulkOperation::Avail { domain } => {
382                let result = self.availability_checker.check(domain).await?;
383                Ok(BulkResultData::Avail(result))
384            }
385            BulkOperation::Info { domain } => {
386                let result = self.smart_lookup.lookup(domain).await?;
387                Ok(BulkResultData::Info(
388                    crate::domain_info::DomainInfo::from_lookup_result(&result),
389                ))
390            }
391            BulkOperation::Ssl { domain } => {
392                let result = self.ssl_checker.check(domain).await?;
393                Ok(BulkResultData::Ssl(result))
394            }
395            BulkOperation::Posture { domain } => {
396                let result = crate::posture::lookup_email_posture(
397                    &self.dns_resolver,
398                    domain,
399                    self.nameserver.as_deref(),
400                )
401                .await?;
402                Ok(BulkResultData::Posture(result))
403            }
404            BulkOperation::Confusables { domain } => {
405                // The single-domain command fans out to `bulk.concurrency`;
406                // here the concurrent scans split that budget, so a batch
407                // does not nest it (50 rows × 50 lookups).
408                let result = crate::confusables::find_confusables_shared(
409                    &self.smart_lookup,
410                    domain,
411                    self.concurrency,
412                    parallel,
413                )
414                .await?;
415                Ok(BulkResultData::Confusables(result))
416            }
417            BulkOperation::Caa { domain } => {
418                // Only invalid input errors (a per-row error); resolver
419                // errors yield an empty policy.
420                let policy =
421                    crate::caa::lookup_caa(&self.dns_resolver, domain, self.nameserver.as_deref())
422                        .await?;
423                Ok(BulkResultData::Caa(policy))
424            }
425        }
426    }
427
428    /// Runs one operation per domain, built by `op` — shorthand for mapping a
429    /// domain list into [`BulkOperation`]s and calling
430    /// [`execute`](Self::execute) without a progress callback:
431    ///
432    /// ```no_run
433    /// # async fn demo(executor: &seer_core::bulk::BulkExecutor) {
434    /// use seer_core::bulk::BulkOperation;
435    /// use seer_core::dns::RecordType;
436    ///
437    /// let domains = vec!["example.com".to_string()];
438    /// let results = executor
439    ///     .execute_each(domains, |domain| BulkOperation::Dns {
440    ///         domain,
441    ///         record_type: RecordType::MX,
442    ///     })
443    ///     .await;
444    /// # }
445    /// ```
446    pub async fn execute_each(
447        &self,
448        domains: Vec<String>,
449        op: impl FnMut(String) -> BulkOperation,
450    ) -> Vec<BulkResult> {
451        self.execute(domains.into_iter().map(op).collect(), None)
452            .await
453    }
454}
455
456/// Trims retained raw WHOIS bodies before a result enters the buffered vec.
457///
458/// `WhoisResponse.raw_response` is `#[serde(skip_serializing)]`, so no bulk
459/// output path (JSON/YAML/CSV) ever emits it — the truncation is invisible
460/// to consumers. Without it, each Whois/Lookup result can retain up to 1 MB
461/// of raw body (the WHOIS client's response cap), which an adversarial batch
462/// could inflate to ~1 GB at the CLI's 1000-domain cap.
463fn trim_result_data(data: BulkResultData) -> BulkResultData {
464    match data {
465        BulkResultData::Whois(mut whois) => {
466            crate::lookup::trim_whois_raw(&mut whois);
467            BulkResultData::Whois(whois)
468        }
469        BulkResultData::Lookup(result) => {
470            BulkResultData::Lookup(crate::lookup::trim_raw_response(result))
471        }
472        other => other,
473    }
474}
475
476/// Spaces dispatches `delay` apart without serializing them.
477///
478/// Each caller locks only long enough to *claim* the next free slot (a
479/// monotonically increasing deadline), then sleeps until that slot with the
480/// lock released — so many callers can wait on distinct slots at once, and
481/// work that starts at a slot overlaps with later callers. Holding the lock
482/// across the wait (the v0.26.7 regression) would serialize everything.
483struct SlotLimiter {
484    delay: Duration,
485    next: Mutex<Option<TokioInstant>>,
486}
487
488impl SlotLimiter {
489    fn new(delay: Duration) -> Self {
490        Self {
491            delay,
492            next: Mutex::new(None),
493        }
494    }
495
496    /// Waits for this caller's dispatch slot.
497    async fn wait(&self) {
498        let my_slot = {
499            let mut next = self.next.lock().await;
500            let now = TokioInstant::now();
501            let slot = match *next {
502                Some(prev) if prev > now => prev,
503                _ => now,
504            };
505            *next = Some(slot + self.delay);
506            slot
507        };
508        sleep_until(my_slot).await;
509    }
510}
511
512/// Parses a plain-text or CSV domain list: one domain per line (the first
513/// column of a CSV row, quotes stripped), `#` comments and blank lines
514/// skipped. Entries without a dot are dropped, which also drops a CSV header
515/// row (`domain,status`).
516pub fn parse_domains_from_file(content: &str) -> Vec<String> {
517    // A UTF-8 BOM (Excel's "CSV UTF-8" export) is not whitespace to
518    // `str::trim`: it would hide a first-line `#` comment and leak invisibly
519    // into the first domain.
520    let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
521    content
522        .lines()
523        .map(|line| line.trim())
524        .filter(|line| !line.is_empty() && !line.starts_with('#'))
525        .map(|line| {
526            // Handle CSV format (take first column). Spreadsheet exports
527            // quote fields (`"example.com","Alice"`); the quotes are CSV
528            // syntax, not part of the domain.
529            line.split(',')
530                .next()
531                .unwrap_or(line)
532                .trim()
533                .trim_matches('"')
534                .trim()
535                .to_string()
536        })
537        .filter(|domain| domain.contains('.'))
538        .collect()
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_parse_domains_from_file() {
547        let content = r#"
548# This is a comment
549example1.com
550google2.com
551  whitespace3.com
552invalid
553csv,format,example.org
554"#;
555
556        let domains = parse_domains_from_file(content);
557        assert_eq!(domains.len(), 3);
558        assert!(domains.contains(&"example1.com".to_string()));
559        assert!(domains.contains(&"google2.com".to_string()));
560        assert!(domains.contains(&"whitespace3.com".to_string()));
561        // "invalid" and "csv" are filtered out because they don't contain a dot
562    }
563
564    #[test]
565    fn parse_domains_handles_bom_and_quoted_csv_fields() {
566        // Excel "CSV UTF-8" export: BOM, quoted fields, a comment first line.
567        let content =
568            "\u{FEFF}# domains for example.com\n\"example.com\",\"Alice\"\n\"test.org\"\n";
569        assert_eq!(
570            parse_domains_from_file(content),
571            vec!["example.com", "test.org"]
572        );
573        // A BOM directly before the first domain must not leak into it.
574        assert_eq!(
575            parse_domains_from_file("\u{FEFF}example.com\n"),
576            vec!["example.com"]
577        );
578    }
579
580    #[test]
581    fn from_config_carries_the_configured_nameserver() {
582        let config = crate::config::SeerConfig {
583            nameserver: Some("9.9.9.9".to_string()),
584            ..Default::default()
585        };
586        assert_eq!(
587            BulkExecutor::from_config(&config).nameserver.as_deref(),
588            Some("9.9.9.9")
589        );
590        assert!(BulkExecutor::new().nameserver.is_none());
591    }
592
593    #[test]
594    fn test_parse_domains_skip_bare_header() {
595        // Bare-keyword header ("domain") is dropped by the dot filter.
596        let content = "domain\nexample.com\n";
597        let domains = parse_domains_from_file(content);
598        assert_eq!(domains, vec!["example.com"]);
599    }
600
601    #[test]
602    fn test_parse_domains_multi_column_csv_header_dropped() {
603        // First column is a bare keyword → whole header row has no dot in col 1
604        // and is dropped by the dot filter.
605        let content = "domain,status\ngoogle.com,live\n";
606        let domains = parse_domains_from_file(content);
607        assert_eq!(domains, vec!["google.com"]);
608        assert!(!domains.contains(&"domain".to_string()));
609    }
610
611    #[test]
612    fn first_domain_with_no_digits_is_kept() {
613        // Regression: previous heuristic dropped the first entry when it
614        // had no ASCII digits, losing "google.com" / "amazon.com" / ...
615        let input = "google.com\namazon.com\n";
616        let result = parse_domains_from_file(input);
617        assert_eq!(result, vec!["google.com", "amazon.com"]);
618    }
619
620    #[test]
621    fn header_row_named_hostname_is_dropped() {
622        let input = "hostname\nexample.com\n";
623        let result = parse_domains_from_file(input);
624        assert_eq!(result, vec!["example.com"]);
625    }
626
627    #[test]
628    fn domain_dot_com_is_not_dropped_as_header() {
629        // "domain.com" is a real domain, not a header, because it has a dot.
630        let input = "domain.com\nexample.com\n";
631        let result = parse_domains_from_file(input);
632        assert_eq!(result, vec!["domain.com", "example.com"]);
633    }
634
635    #[test]
636    fn bulk_operation_domain_returns_domain_for_every_variant() {
637        let d = "example.com".to_string();
638        let ops = vec![
639            BulkOperation::Whois { domain: d.clone() },
640            BulkOperation::Rdap { domain: d.clone() },
641            BulkOperation::Dns {
642                domain: d.clone(),
643                record_type: RecordType::A,
644            },
645            BulkOperation::Propagation {
646                domain: d.clone(),
647                record_type: RecordType::A,
648            },
649            BulkOperation::Lookup { domain: d.clone() },
650            BulkOperation::Status { domain: d.clone() },
651            BulkOperation::Avail { domain: d.clone() },
652            BulkOperation::Info { domain: d.clone() },
653            BulkOperation::Ssl { domain: d.clone() },
654            BulkOperation::Posture { domain: d.clone() },
655            BulkOperation::Confusables { domain: d.clone() },
656            BulkOperation::Caa { domain: d.clone() },
657        ];
658        for op in &ops {
659            assert_eq!(op.domain(), "example.com", "variant {:?}", op);
660        }
661    }
662
663    /// The serde tags on the new variants are part of the public bulk API
664    /// (REST/MCP consumers match on `type` / `result_type`); pin them.
665    #[test]
666    fn new_bulk_variants_serialize_with_snake_case_tags() {
667        let op = BulkOperation::Posture {
668            domain: "example.com".to_string(),
669        };
670        let json = serde_json::to_string(&op).expect("serialize Posture op");
671        assert!(json.contains(r#""type":"posture""#), "got: {json}");
672
673        let op = BulkOperation::Confusables {
674            domain: "example.com".to_string(),
675        };
676        let json = serde_json::to_string(&op).expect("serialize Confusables op");
677        assert!(json.contains(r#""type":"confusables""#), "got: {json}");
678
679        let op = BulkOperation::Caa {
680            domain: "example.com".to_string(),
681        };
682        let json = serde_json::to_string(&op).expect("serialize Caa op");
683        assert!(json.contains(r#""type":"caa""#), "got: {json}");
684
685        let data = BulkResultData::Caa(CaaPolicy::empty());
686        let json = serde_json::to_string(&data).expect("serialize Caa result");
687        assert!(json.contains(r#""result_type":"caa""#), "got: {json}");
688    }
689
690    /// The three new arms must surface an invalid domain as a per-row failure
691    /// (success=false, sanitized error) instead of panicking or hanging.
692    /// Fully offline: normalization rejects the input before any I/O.
693    #[tokio::test]
694    async fn new_bulk_arms_fail_cleanly_on_invalid_domain() {
695        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
696        let bad = || "not a domain".to_string();
697
698        for (name, op) in [
699            ("posture", BulkOperation::Posture { domain: bad() }),
700            ("confusables", BulkOperation::Confusables { domain: bad() }),
701            ("caa", BulkOperation::Caa { domain: bad() }),
702        ] {
703            let results = executor.execute(vec![op], None).await;
704            assert_eq!(results.len(), 1, "{name}: expected one result");
705            let r = &results[0];
706            assert!(!r.success, "{name}: expected failure for invalid domain");
707            assert!(r.data.is_none(), "{name}: expected no data on failure");
708            let err = r.error.as_deref().unwrap_or("");
709            assert!(!err.is_empty(), "{name}: expected non-empty error");
710            assert_eq!(r.operation.domain(), "not a domain", "{name}");
711        }
712    }
713
714    /// `from_config` must apply bulk pacing settings and clamp concurrency
715    /// through the same builder path as manual construction.
716    #[test]
717    fn from_config_applies_bulk_settings() {
718        let mut config = crate::config::SeerConfig::default();
719        config.bulk.concurrency = 25;
720        config.bulk.rate_limit_ms = 250;
721
722        let executor = BulkExecutor::from_config(&config);
723        assert_eq!(executor.concurrency, 25);
724        assert_eq!(executor.rate_limit_delay, Duration::from_millis(250));
725
726        // Out-of-range concurrency is clamped by the builder (defense in
727        // depth on top of SeerConfig::load's own clamping).
728        config.bulk.concurrency = 500;
729        let executor = BulkExecutor::from_config(&config);
730        assert_eq!(executor.concurrency, 50);
731    }
732
733    /// An oversized WHOIS raw_response must come out truncated when the
734    /// result enters the buffer (`trim_result_data` runs on every Ok result
735    /// in `execute_inner`). 32 KB cap + marker — see lookup::trim_whois_raw.
736    #[test]
737    fn bulk_whois_oversized_raw_response_is_truncated() {
738        const MAX_RAW: usize = 32 * 1024;
739        let raw = "a".repeat(MAX_RAW + 1000);
740        let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
741        assert!(whois.raw_response.len() > MAX_RAW);
742
743        let trimmed = trim_result_data(BulkResultData::Whois(whois));
744        let BulkResultData::Whois(whois) = trimmed else {
745            panic!("expected Whois variant");
746        };
747        assert!(
748            whois.raw_response.len() <= MAX_RAW + "\n... [truncated]".len(),
749            "raw_response not bounded: {} bytes",
750            whois.raw_response.len()
751        );
752        assert!(whois.raw_response.ends_with("[truncated]"));
753    }
754
755    #[test]
756    fn bulk_lookup_oversized_raw_response_is_truncated() {
757        const MAX_RAW: usize = 32 * 1024;
758        let raw = "a".repeat(MAX_RAW + 1000);
759        let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
760
761        let trimmed = trim_result_data(BulkResultData::Lookup(LookupResult::Whois {
762            data: whois,
763            rdap_error: None,
764            rdap_fallback: None,
765            dns_nameservers: None,
766        }));
767        let BulkResultData::Lookup(LookupResult::Whois { data, .. }) = trimmed else {
768            panic!("expected Lookup(Whois) variant");
769        };
770        assert!(data.raw_response.len() <= MAX_RAW + "\n... [truncated]".len());
771        assert!(data.raw_response.ends_with("[truncated]"));
772    }
773
774    /// Regression test for the v0.26.7 rate-limiter regression, which held
775    /// the lock across the wait and so serialized dispatch behind it.
776    ///
777    /// Runs on tokio's paused clock with a controlled 500ms "operation" after
778    /// each slot, so the timing is exact rather than depending on how fast a
779    /// real lookup happens to fail: 4 callers at 50ms spacing must finish at
780    /// 150ms (last slot) + 500ms (its op) = 650ms. Any serialization of the
781    /// callers pushes that to at least 4 × 500ms.
782    #[tokio::test(start_paused = true)]
783    async fn rate_limiter_dispatches_in_parallel_not_serialized() {
784        let limiter = Arc::new(SlotLimiter::new(Duration::from_millis(50)));
785        let start = TokioInstant::now();
786        let slots: Vec<Duration> = stream::iter(0..4)
787            .map(|_| {
788                let limiter = limiter.clone();
789                async move {
790                    limiter.wait().await;
791                    let slot = start.elapsed();
792                    tokio::time::sleep(Duration::from_millis(500)).await;
793                    slot
794                }
795            })
796            .buffer_unordered(4)
797            .collect()
798            .await;
799        let elapsed = start.elapsed();
800
801        let mut slots = slots;
802        slots.sort();
803        assert_eq!(
804            slots,
805            [0, 50, 100, 150].map(Duration::from_millis),
806            "dispatches must be spaced exactly one delay apart"
807        );
808        assert_eq!(
809            elapsed,
810            Duration::from_millis(650),
811            "slow operations must overlap, not run one after another"
812        );
813    }
814
815    #[tokio::test]
816    async fn execute_ssl_failure_path_for_unresolvable_host() {
817        // Verifies the SSL bulk arm wires correctly: an unresolvable hostname
818        // must surface as success=false with a non-empty error string. Uses
819        // the IETF-reserved `.invalid` TLD so this is hermetic.
820        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
821        let results = executor
822            .execute_each(vec!["seer-bulk-ssl-test.invalid".to_string()], |domain| {
823                BulkOperation::Ssl { domain }
824            })
825            .await;
826        assert_eq!(results.len(), 1);
827        let r = &results[0];
828        assert!(!r.success, "expected failure, got success");
829        assert!(r.data.is_none(), "expected no data on failure");
830        let err = r.error.as_deref().unwrap_or("");
831        assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
832        assert!(
833            matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
834            "expected Ssl variant, got {:?}",
835            r.operation
836        );
837    }
838
839    #[tokio::test]
840    async fn execute_streaming_invokes_callback_per_result() {
841        use std::sync::atomic::AtomicUsize;
842        // Hermetic: `.invalid` hosts fail fast at DNS. We only assert the
843        // streaming contract — one callback per operation, and the callback
844        // count equals the returned vec length.
845        let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
846        let seen = Arc::new(AtomicUsize::new(0));
847        let seen_cb = seen.clone();
848        let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
849            seen_cb.fetch_add(1, Ordering::Relaxed);
850        });
851        let operations = vec![
852            BulkOperation::Avail {
853                domain: "seer-stream-1.invalid".to_string(),
854            },
855            BulkOperation::Avail {
856                domain: "seer-stream-2.invalid".to_string(),
857            },
858            BulkOperation::Avail {
859                domain: "seer-stream-3.invalid".to_string(),
860            },
861        ];
862        let results = executor.execute_streaming(operations, cb).await;
863        assert_eq!(results.len(), 3, "all operations should be returned");
864        assert_eq!(
865            seen.load(Ordering::Relaxed),
866            3,
867            "callback must fire exactly once per result"
868        );
869    }
870
871    #[tokio::test]
872    #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
873    async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
874        let executor = BulkExecutor::new();
875        let results = executor
876            .execute_each(vec!["cloudflare.com".to_string()], |domain| {
877                BulkOperation::Ssl { domain }
878            })
879            .await;
880        assert_eq!(results.len(), 1);
881        let r = &results[0];
882        assert!(r.success, "expected success, got error: {:?}", r.error);
883        let Some(BulkResultData::Ssl(ref report)) = r.data else {
884            panic!("expected Ssl data, got {:?}", r.data);
885        };
886        assert!(!report.chain.is_empty(), "chain must not be empty");
887        assert!(report.is_valid, "cloudflare leaf cert should be valid");
888        assert!(
889            report.days_until_expiry > 0,
890            "cert should still be valid in the future, got {} days",
891            report.days_until_expiry
892        );
893        assert!(
894            !report.san_names.is_empty(),
895            "cloudflare cert should have SAN entries"
896        );
897    }
898}