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 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 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 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 let limiter = if self.rate_limit_delay.is_zero() {
260 None
261 } else {
262 Some(Arc::new(SlotLimiter::new(self.rate_limit_delay)))
263 };
264
265 let results: Vec<BulkResult> = stream::iter(operations)
266 .map(|op| {
267 let completed = completed.clone();
268 let progress = progress.as_ref();
269 let on_result = on_result.as_ref();
270 let limiter = limiter.clone();
271 let whois_client = &self.whois_client;
272 let rdap_client = &self.rdap_client;
273 let dns_resolver = &self.dns_resolver;
274 let propagation_checker = &self.propagation_checker;
275 let smart_lookup = &self.smart_lookup;
276 let status_client = &self.status_client;
277 let availability_checker = &self.availability_checker;
278 let ssl_checker = &self.ssl_checker;
279 let nameserver = self.nameserver.as_deref();
280 let confusables_concurrency = self.concurrency;
281
282 async move {
283 if let Some(limiter) = &limiter {
287 limiter.wait().await;
288 }
289
290 let start = std::time::Instant::now();
291 let result = execute_operation(
292 &op,
293 &Clients {
294 whois: whois_client,
295 rdap: rdap_client,
296 dns: dns_resolver,
297 propagation: propagation_checker,
298 lookup: smart_lookup,
299 status: status_client,
300 avail: availability_checker,
301 ssl: ssl_checker,
302 nameserver,
303 confusables_concurrency,
304 },
305 )
306 .await;
307 let duration_ms = start.elapsed().as_millis() as u64;
308
309 let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
310
311 if let Some(progress) = progress {
312 progress(count, total, op.domain());
313 }
314
315 let bulk_result = match result {
316 Ok(data) => BulkResult {
317 operation: op,
318 success: true,
319 data: Some(trim_result_data(data)),
320 error: None,
321 duration_ms,
322 },
323 Err(e) => {
324 debug!(error = %e, "Bulk operation failed");
325 BulkResult {
326 operation: op,
327 success: false,
328 data: None,
329 error: Some(e.sanitized_message()),
332 duration_ms,
333 }
334 }
335 };
336
337 if let Some(on_result) = on_result {
340 on_result(&bulk_result);
341 }
342
343 bulk_result
344 }
345 })
346 .buffer_unordered(self.concurrency)
351 .collect()
352 .await;
353
354 let succeeded = results.iter().filter(|r| r.success).count();
355 let failed = results.iter().filter(|r| !r.success).count();
356 info!(
357 total = total,
358 succeeded = succeeded,
359 failed = failed,
360 "bulk operation completed"
361 );
362
363 results
364 }
365
366 pub async fn execute_whois(&self, domains: Vec<String>) -> Vec<BulkResult> {
367 let operations = domains
368 .into_iter()
369 .map(|domain| BulkOperation::Whois { domain })
370 .collect();
371 self.execute(operations, None).await
372 }
373
374 pub async fn execute_rdap(&self, domains: Vec<String>) -> Vec<BulkResult> {
375 let operations = domains
376 .into_iter()
377 .map(|domain| BulkOperation::Rdap { domain })
378 .collect();
379 self.execute(operations, None).await
380 }
381
382 pub async fn execute_dns(
383 &self,
384 domains: Vec<String>,
385 record_type: RecordType,
386 ) -> Vec<BulkResult> {
387 let operations = domains
388 .into_iter()
389 .map(|domain| BulkOperation::Dns {
390 domain,
391 record_type,
392 })
393 .collect();
394 self.execute(operations, None).await
395 }
396
397 pub async fn execute_propagation(
398 &self,
399 domains: Vec<String>,
400 record_type: RecordType,
401 ) -> Vec<BulkResult> {
402 let operations = domains
403 .into_iter()
404 .map(|domain| BulkOperation::Propagation {
405 domain,
406 record_type,
407 })
408 .collect();
409 self.execute(operations, None).await
410 }
411
412 pub async fn execute_lookup(&self, domains: Vec<String>) -> Vec<BulkResult> {
413 let operations = domains
414 .into_iter()
415 .map(|domain| BulkOperation::Lookup { domain })
416 .collect();
417 self.execute(operations, None).await
418 }
419
420 pub async fn execute_status(&self, domains: Vec<String>) -> Vec<BulkResult> {
421 let operations = domains
422 .into_iter()
423 .map(|domain| BulkOperation::Status { domain })
424 .collect();
425 self.execute(operations, None).await
426 }
427
428 pub async fn execute_avail(&self, domains: Vec<String>) -> Vec<BulkResult> {
429 let operations = domains
430 .into_iter()
431 .map(|domain| BulkOperation::Avail { domain })
432 .collect();
433 self.execute(operations, None).await
434 }
435
436 pub async fn execute_info(&self, domains: Vec<String>) -> Vec<BulkResult> {
437 let operations = domains
438 .into_iter()
439 .map(|domain| BulkOperation::Info { domain })
440 .collect();
441 self.execute(operations, None).await
442 }
443
444 pub async fn execute_ssl(&self, domains: Vec<String>) -> Vec<BulkResult> {
445 let operations = domains
446 .into_iter()
447 .map(|domain| BulkOperation::Ssl { domain })
448 .collect();
449 self.execute(operations, None).await
450 }
451
452 pub async fn execute_posture(&self, domains: Vec<String>) -> Vec<BulkResult> {
453 let operations = domains
454 .into_iter()
455 .map(|domain| BulkOperation::Posture { domain })
456 .collect();
457 self.execute(operations, None).await
458 }
459
460 pub async fn execute_confusables(&self, domains: Vec<String>) -> Vec<BulkResult> {
463 let operations = domains
464 .into_iter()
465 .map(|domain| BulkOperation::Confusables { domain })
466 .collect();
467 self.execute(operations, None).await
468 }
469
470 pub async fn execute_caa(&self, domains: Vec<String>) -> Vec<BulkResult> {
471 let operations = domains
472 .into_iter()
473 .map(|domain| BulkOperation::Caa { domain })
474 .collect();
475 self.execute(operations, None).await
476 }
477}
478
479fn trim_result_data(data: BulkResultData) -> BulkResultData {
487 match data {
488 BulkResultData::Whois(mut whois) => {
489 crate::lookup::trim_whois_raw(&mut whois);
490 BulkResultData::Whois(whois)
491 }
492 BulkResultData::Lookup(result) => {
493 BulkResultData::Lookup(crate::lookup::trim_raw_response(result))
494 }
495 other => other,
496 }
497}
498
499struct SlotLimiter {
507 delay: Duration,
508 next: Mutex<Option<TokioInstant>>,
509}
510
511impl SlotLimiter {
512 fn new(delay: Duration) -> Self {
513 Self {
514 delay,
515 next: Mutex::new(None),
516 }
517 }
518
519 async fn wait(&self) {
521 let my_slot = {
522 let mut next = self.next.lock().await;
523 let now = TokioInstant::now();
524 let slot = match *next {
525 Some(prev) if prev > now => prev,
526 _ => now,
527 };
528 *next = Some(slot + self.delay);
529 slot
530 };
531 sleep_until(my_slot).await;
532 }
533}
534
535struct Clients<'a> {
536 whois: &'a WhoisClient,
537 rdap: &'a RdapClient,
538 dns: &'a DnsResolver,
539 propagation: &'a PropagationChecker,
540 lookup: &'a SmartLookup,
541 status: &'a StatusClient,
542 avail: &'a AvailabilityChecker,
543 ssl: &'a SslChecker,
544 nameserver: Option<&'a str>,
546 confusables_concurrency: usize,
550}
551
552async fn execute_operation(op: &BulkOperation, clients: &Clients<'_>) -> Result<BulkResultData> {
553 match op {
554 BulkOperation::Whois { domain } => {
555 let result = clients.whois.lookup(domain).await?;
556 Ok(BulkResultData::Whois(result))
557 }
558 BulkOperation::Rdap { domain } => {
559 let result = clients.rdap.lookup_domain(domain).await?;
560 Ok(BulkResultData::Rdap(Box::new(result)))
561 }
562 BulkOperation::Dns {
563 domain,
564 record_type,
565 } => {
566 let result = clients
567 .dns
568 .resolve(domain, *record_type, clients.nameserver)
569 .await?;
570 Ok(BulkResultData::Dns(result))
571 }
572 BulkOperation::Propagation {
573 domain,
574 record_type,
575 } => {
576 let result = clients.propagation.check(domain, *record_type).await?;
577 Ok(BulkResultData::Propagation(result))
578 }
579 BulkOperation::Lookup { domain } => {
580 let result = clients.lookup.lookup(domain).await?;
581 Ok(BulkResultData::Lookup(result))
582 }
583 BulkOperation::Status { domain } => {
584 let result = clients.status.check(domain).await?;
585 Ok(BulkResultData::Status(result))
586 }
587 BulkOperation::Avail { domain } => {
588 let result = clients.avail.check(domain).await?;
589 Ok(BulkResultData::Avail(result))
590 }
591 BulkOperation::Info { domain } => {
592 let result = clients.lookup.lookup(domain).await?;
593 Ok(BulkResultData::Info(
594 crate::domain_info::DomainInfo::from_lookup_result(&result),
595 ))
596 }
597 BulkOperation::Ssl { domain } => {
598 let result = clients.ssl.check(domain).await?;
599 Ok(BulkResultData::Ssl(result))
600 }
601 BulkOperation::Posture { domain } => {
602 let result = crate::posture::lookup_email_posture(clients.dns, domain).await?;
603 Ok(BulkResultData::Posture(result))
604 }
605 BulkOperation::Confusables { domain } => {
606 let result = crate::confusables::find_confusables(
607 clients.lookup,
608 domain,
609 clients.confusables_concurrency,
610 )
611 .await?;
612 Ok(BulkResultData::Confusables(result))
613 }
614 BulkOperation::Caa { domain } => {
615 let domain = crate::validation::normalize_domain(domain)?;
620 let policy = crate::caa::lookup_caa(clients.dns, &domain).await;
621 Ok(BulkResultData::Caa(policy))
622 }
623 }
624}
625
626const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
628
629fn is_csv_header_row(first: &str) -> bool {
636 let first_col = first.split(',').next().unwrap_or(first).trim();
637 if first_col.contains('.') {
639 return false;
640 }
641 let label = first_col.to_lowercase();
642 HEADER_KEYWORDS.contains(&label.as_str())
643}
644
645pub fn parse_domains_from_file(content: &str) -> Vec<String> {
646 let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
650 let mut domains: Vec<String> = content
651 .lines()
652 .map(|line| line.trim())
653 .filter(|line| !line.is_empty() && !line.starts_with('#'))
654 .map(|line| {
655 line.split(',')
659 .next()
660 .unwrap_or(line)
661 .trim()
662 .trim_matches('"')
663 .trim()
664 .to_string()
665 })
666 .filter(|domain| domain.contains('.'))
667 .collect();
668
669 if domains
677 .first()
678 .map(|d| is_csv_header_row(d))
679 .unwrap_or(false)
680 {
681 domains.remove(0);
682 }
683
684 domains
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn test_parse_domains_from_file() {
693 let content = r#"
694# This is a comment
695example1.com
696google2.com
697 whitespace3.com
698invalid
699csv,format,example.org
700"#;
701
702 let domains = parse_domains_from_file(content);
703 assert_eq!(domains.len(), 3);
704 assert!(domains.contains(&"example1.com".to_string()));
705 assert!(domains.contains(&"google2.com".to_string()));
706 assert!(domains.contains(&"whitespace3.com".to_string()));
707 }
709
710 #[test]
711 fn parse_domains_handles_bom_and_quoted_csv_fields() {
712 let content =
714 "\u{FEFF}# domains for example.com\n\"example.com\",\"Alice\"\n\"test.org\"\n";
715 assert_eq!(
716 parse_domains_from_file(content),
717 vec!["example.com", "test.org"]
718 );
719 assert_eq!(
721 parse_domains_from_file("\u{FEFF}example.com\n"),
722 vec!["example.com"]
723 );
724 }
725
726 #[test]
727 fn from_config_carries_the_configured_nameserver() {
728 let config = crate::config::SeerConfig {
729 nameserver: Some("9.9.9.9".to_string()),
730 ..Default::default()
731 };
732 assert_eq!(
733 BulkExecutor::from_config(&config).nameserver.as_deref(),
734 Some("9.9.9.9")
735 );
736 assert!(BulkExecutor::new().nameserver.is_none());
737 }
738
739 #[test]
740 fn test_parse_domains_skip_bare_header() {
741 let content = "domain\nexample.com\n";
743 let domains = parse_domains_from_file(content);
744 assert_eq!(domains, vec!["example.com"]);
745 }
746
747 #[test]
748 fn test_parse_domains_multi_column_csv_header_dropped() {
749 let content = "domain,status\ngoogle.com,live\n";
752 let domains = parse_domains_from_file(content);
753 assert_eq!(domains, vec!["google.com"]);
754 assert!(!domains.contains(&"domain".to_string()));
755 }
756
757 #[test]
758 fn first_domain_with_no_digits_is_kept() {
759 let input = "google.com\namazon.com\n";
762 let result = parse_domains_from_file(input);
763 assert_eq!(result, vec!["google.com", "amazon.com"]);
764 }
765
766 #[test]
767 fn header_row_named_hostname_is_dropped() {
768 let input = "hostname\nexample.com\n";
769 let result = parse_domains_from_file(input);
770 assert_eq!(result, vec!["example.com"]);
771 }
772
773 #[test]
774 fn domain_dot_com_is_not_dropped_as_header() {
775 let input = "domain.com\nexample.com\n";
777 let result = parse_domains_from_file(input);
778 assert_eq!(result, vec!["domain.com", "example.com"]);
779 }
780
781 #[test]
782 fn is_csv_header_row_detects_bare_keywords() {
783 assert!(is_csv_header_row("domain"));
784 assert!(is_csv_header_row("Hostname"));
785 assert!(is_csv_header_row("URL"));
786 assert!(is_csv_header_row("domain,status,notes"));
787 assert!(is_csv_header_row(" host "));
788 }
789
790 #[test]
791 fn is_csv_header_row_rejects_dotted_values() {
792 assert!(!is_csv_header_row("domain.com"));
793 assert!(!is_csv_header_row("google.com"));
794 assert!(!is_csv_header_row("host.name"));
795 }
796
797 #[test]
798 fn is_csv_header_row_rejects_non_keyword() {
799 assert!(!is_csv_header_row("example"));
800 assert!(!is_csv_header_row("mydata"));
801 }
802
803 #[test]
804 fn bulk_operation_domain_returns_domain_for_every_variant() {
805 let d = "example.com".to_string();
806 let ops = vec![
807 BulkOperation::Whois { domain: d.clone() },
808 BulkOperation::Rdap { domain: d.clone() },
809 BulkOperation::Dns {
810 domain: d.clone(),
811 record_type: RecordType::A,
812 },
813 BulkOperation::Propagation {
814 domain: d.clone(),
815 record_type: RecordType::A,
816 },
817 BulkOperation::Lookup { domain: d.clone() },
818 BulkOperation::Status { domain: d.clone() },
819 BulkOperation::Avail { domain: d.clone() },
820 BulkOperation::Info { domain: d.clone() },
821 BulkOperation::Ssl { domain: d.clone() },
822 BulkOperation::Posture { domain: d.clone() },
823 BulkOperation::Confusables { domain: d.clone() },
824 BulkOperation::Caa { domain: d.clone() },
825 ];
826 for op in &ops {
827 assert_eq!(op.domain(), "example.com", "variant {:?}", op);
828 }
829 }
830
831 #[test]
834 fn new_bulk_variants_serialize_with_snake_case_tags() {
835 let op = BulkOperation::Posture {
836 domain: "example.com".to_string(),
837 };
838 let json = serde_json::to_string(&op).expect("serialize Posture op");
839 assert!(json.contains(r#""type":"posture""#), "got: {json}");
840
841 let op = BulkOperation::Confusables {
842 domain: "example.com".to_string(),
843 };
844 let json = serde_json::to_string(&op).expect("serialize Confusables op");
845 assert!(json.contains(r#""type":"confusables""#), "got: {json}");
846
847 let op = BulkOperation::Caa {
848 domain: "example.com".to_string(),
849 };
850 let json = serde_json::to_string(&op).expect("serialize Caa op");
851 assert!(json.contains(r#""type":"caa""#), "got: {json}");
852
853 let data = BulkResultData::Caa(CaaPolicy::empty());
854 let json = serde_json::to_string(&data).expect("serialize Caa result");
855 assert!(json.contains(r#""result_type":"caa""#), "got: {json}");
856 }
857
858 #[tokio::test]
862 async fn new_bulk_arms_fail_cleanly_on_invalid_domain() {
863 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
864 let bad = "not a domain".to_string();
865
866 for (name, results) in [
867 ("posture", executor.execute_posture(vec![bad.clone()]).await),
868 (
869 "confusables",
870 executor.execute_confusables(vec![bad.clone()]).await,
871 ),
872 ("caa", executor.execute_caa(vec![bad.clone()]).await),
873 ] {
874 assert_eq!(results.len(), 1, "{name}: expected one result");
875 let r = &results[0];
876 assert!(!r.success, "{name}: expected failure for invalid domain");
877 assert!(r.data.is_none(), "{name}: expected no data on failure");
878 let err = r.error.as_deref().unwrap_or("");
879 assert!(!err.is_empty(), "{name}: expected non-empty error");
880 assert_eq!(r.operation.domain(), "not a domain", "{name}");
881 }
882 }
883
884 #[test]
887 fn from_config_applies_bulk_settings() {
888 let mut config = crate::config::SeerConfig::default();
889 config.bulk.concurrency = 25;
890 config.bulk.rate_limit_ms = 250;
891
892 let executor = BulkExecutor::from_config(&config);
893 assert_eq!(executor.concurrency, 25);
894 assert_eq!(executor.rate_limit_delay, Duration::from_millis(250));
895
896 config.bulk.concurrency = 500;
899 let executor = BulkExecutor::from_config(&config);
900 assert_eq!(executor.concurrency, 50);
901 }
902
903 #[test]
907 fn bulk_whois_oversized_raw_response_is_truncated() {
908 const MAX_RAW: usize = 32 * 1024;
909 let raw = "a".repeat(MAX_RAW + 1000);
910 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
911 assert!(whois.raw_response.len() > MAX_RAW);
912
913 let trimmed = trim_result_data(BulkResultData::Whois(whois));
914 let BulkResultData::Whois(whois) = trimmed else {
915 panic!("expected Whois variant");
916 };
917 assert!(
918 whois.raw_response.len() <= MAX_RAW + "\n... [truncated]".len(),
919 "raw_response not bounded: {} bytes",
920 whois.raw_response.len()
921 );
922 assert!(whois.raw_response.ends_with("[truncated]"));
923 }
924
925 #[test]
926 fn bulk_lookup_oversized_raw_response_is_truncated() {
927 const MAX_RAW: usize = 32 * 1024;
928 let raw = "a".repeat(MAX_RAW + 1000);
929 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
930
931 let trimmed = trim_result_data(BulkResultData::Lookup(LookupResult::Whois {
932 data: whois,
933 rdap_error: None,
934 rdap_fallback: None,
935 }));
936 let BulkResultData::Lookup(LookupResult::Whois { data, .. }) = trimmed else {
937 panic!("expected Lookup(Whois) variant");
938 };
939 assert!(data.raw_response.len() <= MAX_RAW + "\n... [truncated]".len());
940 assert!(data.raw_response.ends_with("[truncated]"));
941 }
942
943 #[tokio::test(start_paused = true)]
952 async fn rate_limiter_dispatches_in_parallel_not_serialized() {
953 let limiter = Arc::new(SlotLimiter::new(Duration::from_millis(50)));
954 let start = TokioInstant::now();
955 let slots: Vec<Duration> = stream::iter(0..4)
956 .map(|_| {
957 let limiter = limiter.clone();
958 async move {
959 limiter.wait().await;
960 let slot = start.elapsed();
961 tokio::time::sleep(Duration::from_millis(500)).await;
962 slot
963 }
964 })
965 .buffer_unordered(4)
966 .collect()
967 .await;
968 let elapsed = start.elapsed();
969
970 let mut slots = slots;
971 slots.sort();
972 assert_eq!(
973 slots,
974 [0, 50, 100, 150].map(Duration::from_millis),
975 "dispatches must be spaced exactly one delay apart"
976 );
977 assert_eq!(
978 elapsed,
979 Duration::from_millis(650),
980 "slow operations must overlap, not run one after another"
981 );
982 }
983
984 #[tokio::test]
985 async fn execute_ssl_failure_path_for_unresolvable_host() {
986 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
990 let results = executor
991 .execute_ssl(vec!["seer-bulk-ssl-test.invalid".to_string()])
992 .await;
993 assert_eq!(results.len(), 1);
994 let r = &results[0];
995 assert!(!r.success, "expected failure, got success");
996 assert!(r.data.is_none(), "expected no data on failure");
997 let err = r.error.as_deref().unwrap_or("");
998 assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
999 assert!(
1000 matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
1001 "expected Ssl variant, got {:?}",
1002 r.operation
1003 );
1004 }
1005
1006 #[tokio::test]
1007 async fn execute_streaming_invokes_callback_per_result() {
1008 use std::sync::atomic::AtomicUsize;
1009 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
1013 let seen = Arc::new(AtomicUsize::new(0));
1014 let seen_cb = seen.clone();
1015 let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
1016 seen_cb.fetch_add(1, Ordering::Relaxed);
1017 });
1018 let operations = vec![
1019 BulkOperation::Avail {
1020 domain: "seer-stream-1.invalid".to_string(),
1021 },
1022 BulkOperation::Avail {
1023 domain: "seer-stream-2.invalid".to_string(),
1024 },
1025 BulkOperation::Avail {
1026 domain: "seer-stream-3.invalid".to_string(),
1027 },
1028 ];
1029 let results = executor.execute_streaming(operations, cb).await;
1030 assert_eq!(results.len(), 3, "all operations should be returned");
1031 assert_eq!(
1032 seen.load(Ordering::Relaxed),
1033 3,
1034 "callback must fire exactly once per result"
1035 );
1036 }
1037
1038 #[tokio::test]
1039 #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
1040 async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
1041 let executor = BulkExecutor::new();
1042 let results = executor
1043 .execute_ssl(vec!["cloudflare.com".to_string()])
1044 .await;
1045 assert_eq!(results.len(), 1);
1046 let r = &results[0];
1047 assert!(r.success, "expected success, got error: {:?}", r.error);
1048 let Some(BulkResultData::Ssl(ref report)) = r.data else {
1049 panic!("expected Ssl data, got {:?}", r.data);
1050 };
1051 assert!(!report.chain.is_empty(), "chain must not be empty");
1052 assert!(report.is_valid, "cloudflare leaf cert should be valid");
1053 assert!(
1054 report.days_until_expiry > 0,
1055 "cert should still be valid in the future, got {} days",
1056 report.days_until_expiry
1057 );
1058 assert!(
1059 !report.san_names.is_empty(),
1060 "cloudflare cert should have SAN entries"
1061 );
1062 }
1063}