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
25pub type ResultCallback = Box<dyn Fn(&BulkResult) + Send + Sync>;
32
33#[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 Posture {
68 domain: String,
69 },
70 Confusables {
80 domain: String,
81 },
82 Caa {
84 domain: String,
85 },
86}
87
88impl BulkOperation {
89 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#[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#[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#[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 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 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 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 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 error: Some(e.sanitized_message()),
336 duration_ms,
337 }
338 }
339 };
340
341 if let Some(on_result) = on_result {
344 on_result(&bulk_result);
345 }
346
347 bulk_result
348 }
349 })
350 .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 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
483fn 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 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 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
589const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
591
592fn is_csv_header_row(first: &str) -> bool {
599 let first_col = first.split(',').next().unwrap_or(first).trim();
600 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 line.split(',').next().unwrap_or(line).trim().to_string()
616 })
617 .filter(|domain| domain.contains('.'))
618 .collect();
619
620 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 }
660
661 #[test]
662 fn test_parse_domains_skip_bare_header() {
663 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 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 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 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 #[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 #[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 #[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 config.bulk.concurrency = 500;
821 let executor = BulkExecutor::from_config(&config);
822 assert_eq!(executor.concurrency, 50);
823 }
824
825 #[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 #[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 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 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 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 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}