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::dns::{DnsRecord, DnsResolver, PropagationChecker, PropagationResult, RecordType};
13use crate::error::Result;
14use crate::lookup::{LookupResult, SmartLookup};
15use crate::rdap::{RdapClient, RdapResponse};
16use crate::ssl::{SslChecker, SslReport};
17use crate::status::{StatusClient, StatusResponse};
18use crate::whois::{WhoisClient, WhoisResponse};
19
20pub type ProgressCallback = Box<dyn Fn(usize, usize, &str) + Send + Sync>;
21
22pub type ResultCallback = Box<dyn Fn(&BulkResult) + Send + Sync>;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum BulkOperation {
34 Whois {
35 domain: String,
36 },
37 Rdap {
38 domain: String,
39 },
40 Dns {
41 domain: String,
42 record_type: RecordType,
43 },
44 Propagation {
45 domain: String,
46 record_type: RecordType,
47 },
48 Lookup {
49 domain: String,
50 },
51 Status {
52 domain: String,
53 },
54 Avail {
55 domain: String,
56 },
57 Info {
58 domain: String,
59 },
60 Ssl {
61 domain: String,
62 },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(tag = "result_type", content = "data", rename_all = "snake_case")]
68pub enum BulkResultData {
69 Whois(WhoisResponse),
70 Rdap(Box<RdapResponse>),
71 Dns(Vec<DnsRecord>),
72 Propagation(PropagationResult),
73 Lookup(LookupResult),
74 Status(StatusResponse),
75 Avail(AvailabilityResult),
76 Info(crate::domain_info::DomainInfo),
77 Ssl(SslReport),
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct BulkResult {
83 pub operation: BulkOperation,
84 pub success: bool,
85 pub data: Option<BulkResultData>,
86 pub error: Option<String>,
87 pub duration_ms: u64,
88}
89
90#[derive(Debug, Clone)]
92pub struct BulkExecutor {
93 concurrency: usize,
94 rate_limit_delay: Duration,
95 whois_client: WhoisClient,
96 rdap_client: RdapClient,
97 dns_resolver: DnsResolver,
98 propagation_checker: PropagationChecker,
99 smart_lookup: SmartLookup,
100 status_client: StatusClient,
101 availability_checker: AvailabilityChecker,
102 ssl_checker: SslChecker,
103}
104
105impl Default for BulkExecutor {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl BulkExecutor {
112 pub fn new() -> Self {
113 Self {
114 concurrency: 10,
115 rate_limit_delay: Duration::from_millis(100),
116 whois_client: WhoisClient::new(),
117 rdap_client: RdapClient::new(),
118 dns_resolver: DnsResolver::new(),
119 propagation_checker: PropagationChecker::new(),
120 smart_lookup: SmartLookup::new(),
121 status_client: StatusClient::new(),
122 availability_checker: AvailabilityChecker::new(),
123 ssl_checker: SslChecker::new(),
124 }
125 }
126
127 pub fn with_concurrency(mut self, concurrency: usize) -> Self {
128 self.concurrency = concurrency.clamp(1, 50);
129 self
130 }
131
132 pub fn with_rate_limit(mut self, delay: Duration) -> Self {
133 self.rate_limit_delay = delay;
134 self
135 }
136
137 #[instrument(skip(self, operations, progress), fields(count = operations.len(), concurrency = self.concurrency))]
138 pub async fn execute(
139 &self,
140 operations: Vec<BulkOperation>,
141 progress: Option<ProgressCallback>,
142 ) -> Vec<BulkResult> {
143 self.execute_inner(operations, progress, None).await
144 }
145
146 pub async fn execute_streaming(
150 &self,
151 operations: Vec<BulkOperation>,
152 on_result: ResultCallback,
153 ) -> Vec<BulkResult> {
154 self.execute_inner(operations, None, Some(on_result)).await
155 }
156
157 #[instrument(skip(self, operations, progress, on_result), fields(count = operations.len(), concurrency = self.concurrency))]
158 async fn execute_inner(
159 &self,
160 operations: Vec<BulkOperation>,
161 progress: Option<ProgressCallback>,
162 on_result: Option<ResultCallback>,
163 ) -> Vec<BulkResult> {
164 let total = operations.len();
165 let completed = Arc::new(AtomicUsize::new(0));
166
167 info!("bulk operation started");
168 debug!(
169 total = total,
170 concurrency = self.concurrency,
171 "Starting bulk execution"
172 );
173
174 let limiter = if self.rate_limit_delay.is_zero() {
187 None
188 } else {
189 Some(Arc::new(Mutex::new(None::<TokioInstant>)))
190 };
191 let rate_limit_delay = self.rate_limit_delay;
192
193 let results: Vec<BulkResult> = stream::iter(operations)
194 .map(|op| {
195 let completed = completed.clone();
196 let progress = progress.as_ref();
197 let on_result = on_result.as_ref();
198 let limiter = limiter.clone();
199 let whois_client = &self.whois_client;
200 let rdap_client = &self.rdap_client;
201 let dns_resolver = &self.dns_resolver;
202 let propagation_checker = &self.propagation_checker;
203 let smart_lookup = &self.smart_lookup;
204 let status_client = &self.status_client;
205 let availability_checker = &self.availability_checker;
206 let ssl_checker = &self.ssl_checker;
207
208 async move {
209 if let Some(limiter) = &limiter {
213 let my_slot = {
214 let mut next = limiter.lock().await;
215 let now = TokioInstant::now();
216 let slot = match *next {
217 Some(prev) if prev > now => prev,
218 _ => now,
219 };
220 *next = Some(slot + rate_limit_delay);
221 slot
222 };
223 sleep_until(my_slot).await;
224 }
225
226 let start = std::time::Instant::now();
227 let result = execute_operation(
228 &op,
229 &Clients {
230 whois: whois_client,
231 rdap: rdap_client,
232 dns: dns_resolver,
233 propagation: propagation_checker,
234 lookup: smart_lookup,
235 status: status_client,
236 avail: availability_checker,
237 ssl: ssl_checker,
238 },
239 )
240 .await;
241 let duration_ms = start.elapsed().as_millis() as u64;
242
243 let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
244
245 if let Some(progress) = progress {
246 let desc = match &op {
247 BulkOperation::Whois { domain }
248 | BulkOperation::Rdap { domain }
249 | BulkOperation::Dns { domain, .. }
250 | BulkOperation::Propagation { domain, .. }
251 | BulkOperation::Lookup { domain }
252 | BulkOperation::Status { domain }
253 | BulkOperation::Avail { domain }
254 | BulkOperation::Info { domain }
255 | BulkOperation::Ssl { domain } => domain.as_str(),
256 };
257 progress(count, total, desc);
258 }
259
260 let bulk_result = match result {
261 Ok(data) => BulkResult {
262 operation: op,
263 success: true,
264 data: Some(data),
265 error: None,
266 duration_ms,
267 },
268 Err(e) => {
269 debug!(error = %e, "Bulk operation failed");
270 BulkResult {
271 operation: op,
272 success: false,
273 data: None,
274 error: Some(e.sanitized_message()),
277 duration_ms,
278 }
279 }
280 };
281
282 if let Some(on_result) = on_result {
285 on_result(&bulk_result);
286 }
287
288 bulk_result
289 }
290 })
291 .buffer_unordered(self.concurrency)
296 .collect()
297 .await;
298
299 let succeeded = results.iter().filter(|r| r.success).count();
300 let failed = results.iter().filter(|r| !r.success).count();
301 info!(
302 total = total,
303 succeeded = succeeded,
304 failed = failed,
305 "bulk operation completed"
306 );
307
308 results
309 }
310
311 pub async fn execute_whois(&self, domains: Vec<String>) -> Vec<BulkResult> {
312 let operations = domains
313 .into_iter()
314 .map(|domain| BulkOperation::Whois { domain })
315 .collect();
316 self.execute(operations, None).await
317 }
318
319 pub async fn execute_rdap(&self, domains: Vec<String>) -> Vec<BulkResult> {
320 let operations = domains
321 .into_iter()
322 .map(|domain| BulkOperation::Rdap { domain })
323 .collect();
324 self.execute(operations, None).await
325 }
326
327 pub async fn execute_dns(
328 &self,
329 domains: Vec<String>,
330 record_type: RecordType,
331 ) -> Vec<BulkResult> {
332 let operations = domains
333 .into_iter()
334 .map(|domain| BulkOperation::Dns {
335 domain,
336 record_type,
337 })
338 .collect();
339 self.execute(operations, None).await
340 }
341
342 pub async fn execute_propagation(
343 &self,
344 domains: Vec<String>,
345 record_type: RecordType,
346 ) -> Vec<BulkResult> {
347 let operations = domains
348 .into_iter()
349 .map(|domain| BulkOperation::Propagation {
350 domain,
351 record_type,
352 })
353 .collect();
354 self.execute(operations, None).await
355 }
356
357 pub async fn execute_lookup(&self, domains: Vec<String>) -> Vec<BulkResult> {
358 let operations = domains
359 .into_iter()
360 .map(|domain| BulkOperation::Lookup { domain })
361 .collect();
362 self.execute(operations, None).await
363 }
364
365 pub async fn execute_status(&self, domains: Vec<String>) -> Vec<BulkResult> {
366 let operations = domains
367 .into_iter()
368 .map(|domain| BulkOperation::Status { domain })
369 .collect();
370 self.execute(operations, None).await
371 }
372
373 pub async fn execute_avail(&self, domains: Vec<String>) -> Vec<BulkResult> {
374 let operations = domains
375 .into_iter()
376 .map(|domain| BulkOperation::Avail { domain })
377 .collect();
378 self.execute(operations, None).await
379 }
380
381 pub async fn execute_info(&self, domains: Vec<String>) -> Vec<BulkResult> {
382 let operations = domains
383 .into_iter()
384 .map(|domain| BulkOperation::Info { domain })
385 .collect();
386 self.execute(operations, None).await
387 }
388
389 pub async fn execute_ssl(&self, domains: Vec<String>) -> Vec<BulkResult> {
390 let operations = domains
391 .into_iter()
392 .map(|domain| BulkOperation::Ssl { domain })
393 .collect();
394 self.execute(operations, None).await
395 }
396}
397
398struct Clients<'a> {
399 whois: &'a WhoisClient,
400 rdap: &'a RdapClient,
401 dns: &'a DnsResolver,
402 propagation: &'a PropagationChecker,
403 lookup: &'a SmartLookup,
404 status: &'a StatusClient,
405 avail: &'a AvailabilityChecker,
406 ssl: &'a SslChecker,
407}
408
409async fn execute_operation(op: &BulkOperation, clients: &Clients<'_>) -> Result<BulkResultData> {
410 match op {
411 BulkOperation::Whois { domain } => {
412 let result = clients.whois.lookup(domain).await?;
413 Ok(BulkResultData::Whois(result))
414 }
415 BulkOperation::Rdap { domain } => {
416 let result = clients.rdap.lookup_domain(domain).await?;
417 Ok(BulkResultData::Rdap(Box::new(result)))
418 }
419 BulkOperation::Dns {
420 domain,
421 record_type,
422 } => {
423 let result = clients.dns.resolve(domain, *record_type, None).await?;
424 Ok(BulkResultData::Dns(result))
425 }
426 BulkOperation::Propagation {
427 domain,
428 record_type,
429 } => {
430 let result = clients.propagation.check(domain, *record_type).await?;
431 Ok(BulkResultData::Propagation(result))
432 }
433 BulkOperation::Lookup { domain } => {
434 let result = clients.lookup.lookup(domain).await?;
435 Ok(BulkResultData::Lookup(result))
436 }
437 BulkOperation::Status { domain } => {
438 let result = clients.status.check(domain).await?;
439 Ok(BulkResultData::Status(result))
440 }
441 BulkOperation::Avail { domain } => {
442 let result = clients.avail.check(domain).await?;
443 Ok(BulkResultData::Avail(result))
444 }
445 BulkOperation::Info { domain } => {
446 let result = clients.lookup.lookup(domain).await?;
447 Ok(BulkResultData::Info(
448 crate::domain_info::DomainInfo::from_lookup_result(&result),
449 ))
450 }
451 BulkOperation::Ssl { domain } => {
452 let result = clients.ssl.check(domain).await?;
453 Ok(BulkResultData::Ssl(result))
454 }
455 }
456}
457
458const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
460
461fn is_csv_header_row(first: &str) -> bool {
468 let first_col = first.split(',').next().unwrap_or(first).trim();
469 if first_col.contains('.') {
471 return false;
472 }
473 let label = first_col.to_lowercase();
474 HEADER_KEYWORDS.contains(&label.as_str())
475}
476
477pub fn parse_domains_from_file(content: &str) -> Vec<String> {
478 let mut domains: Vec<String> = content
479 .lines()
480 .map(|line| line.trim())
481 .filter(|line| !line.is_empty() && !line.starts_with('#'))
482 .map(|line| {
483 line.split(',').next().unwrap_or(line).trim().to_string()
485 })
486 .filter(|domain| domain.contains('.'))
487 .collect();
488
489 if domains
497 .first()
498 .map(|d| is_csv_header_row(d))
499 .unwrap_or(false)
500 {
501 domains.remove(0);
502 }
503
504 domains
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_parse_domains_from_file() {
513 let content = r#"
514# This is a comment
515example1.com
516google2.com
517 whitespace3.com
518invalid
519csv,format,example.org
520"#;
521
522 let domains = parse_domains_from_file(content);
523 assert_eq!(domains.len(), 3);
524 assert!(domains.contains(&"example1.com".to_string()));
525 assert!(domains.contains(&"google2.com".to_string()));
526 assert!(domains.contains(&"whitespace3.com".to_string()));
527 }
529
530 #[test]
531 fn test_parse_domains_skip_bare_header() {
532 let content = "domain\nexample.com\n";
534 let domains = parse_domains_from_file(content);
535 assert_eq!(domains, vec!["example.com"]);
536 }
537
538 #[test]
539 fn test_parse_domains_multi_column_csv_header_dropped() {
540 let content = "domain,status\ngoogle.com,live\n";
543 let domains = parse_domains_from_file(content);
544 assert_eq!(domains, vec!["google.com"]);
545 assert!(!domains.contains(&"domain".to_string()));
546 }
547
548 #[test]
549 fn first_domain_with_no_digits_is_kept() {
550 let input = "google.com\namazon.com\n";
553 let result = parse_domains_from_file(input);
554 assert_eq!(result, vec!["google.com", "amazon.com"]);
555 }
556
557 #[test]
558 fn header_row_named_hostname_is_dropped() {
559 let input = "hostname\nexample.com\n";
560 let result = parse_domains_from_file(input);
561 assert_eq!(result, vec!["example.com"]);
562 }
563
564 #[test]
565 fn domain_dot_com_is_not_dropped_as_header() {
566 let input = "domain.com\nexample.com\n";
568 let result = parse_domains_from_file(input);
569 assert_eq!(result, vec!["domain.com", "example.com"]);
570 }
571
572 #[test]
573 fn is_csv_header_row_detects_bare_keywords() {
574 assert!(is_csv_header_row("domain"));
575 assert!(is_csv_header_row("Hostname"));
576 assert!(is_csv_header_row("URL"));
577 assert!(is_csv_header_row("domain,status,notes"));
578 assert!(is_csv_header_row(" host "));
579 }
580
581 #[test]
582 fn is_csv_header_row_rejects_dotted_values() {
583 assert!(!is_csv_header_row("domain.com"));
584 assert!(!is_csv_header_row("google.com"));
585 assert!(!is_csv_header_row("host.name"));
586 }
587
588 #[test]
589 fn is_csv_header_row_rejects_non_keyword() {
590 assert!(!is_csv_header_row("example"));
591 assert!(!is_csv_header_row("mydata"));
592 }
593
594 #[tokio::test]
610 async fn rate_limiter_dispatches_in_parallel_not_serialized() {
611 use std::time::Instant;
612 let executor = BulkExecutor::new()
613 .with_concurrency(5)
614 .with_rate_limit(Duration::from_millis(50));
615
616 let start = Instant::now();
623 let domains = vec![
624 "seer-rl-1.invalid".to_string(),
625 "seer-rl-2.invalid".to_string(),
626 "seer-rl-3.invalid".to_string(),
627 "seer-rl-4.invalid".to_string(),
628 ];
629 let results = executor.execute_ssl(domains).await;
630 let elapsed = start.elapsed();
631
632 assert_eq!(results.len(), 4);
633 assert!(
637 elapsed < Duration::from_secs(2),
638 "rate-limited dispatch should run in parallel; took {:?}",
639 elapsed
640 );
641 }
642
643 #[tokio::test]
644 async fn execute_ssl_failure_path_for_unresolvable_host() {
645 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
649 let results = executor
650 .execute_ssl(vec!["seer-bulk-ssl-test.invalid".to_string()])
651 .await;
652 assert_eq!(results.len(), 1);
653 let r = &results[0];
654 assert!(!r.success, "expected failure, got success");
655 assert!(r.data.is_none(), "expected no data on failure");
656 let err = r.error.as_deref().unwrap_or("");
657 assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
658 assert!(
659 matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
660 "expected Ssl variant, got {:?}",
661 r.operation
662 );
663 }
664
665 #[tokio::test]
666 async fn execute_streaming_invokes_callback_per_result() {
667 use std::sync::atomic::AtomicUsize;
668 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
672 let seen = Arc::new(AtomicUsize::new(0));
673 let seen_cb = seen.clone();
674 let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
675 seen_cb.fetch_add(1, Ordering::Relaxed);
676 });
677 let operations = vec![
678 BulkOperation::Avail {
679 domain: "seer-stream-1.invalid".to_string(),
680 },
681 BulkOperation::Avail {
682 domain: "seer-stream-2.invalid".to_string(),
683 },
684 BulkOperation::Avail {
685 domain: "seer-stream-3.invalid".to_string(),
686 },
687 ];
688 let results = executor.execute_streaming(operations, cb).await;
689 assert_eq!(results.len(), 3, "all operations should be returned");
690 assert_eq!(
691 seen.load(Ordering::Relaxed),
692 3,
693 "callback must fire exactly once per result"
694 );
695 }
696
697 #[tokio::test]
698 #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
699 async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
700 let executor = BulkExecutor::new();
701 let results = executor
702 .execute_ssl(vec!["cloudflare.com".to_string()])
703 .await;
704 assert_eq!(results.len(), 1);
705 let r = &results[0];
706 assert!(r.success, "expected success, got error: {:?}", r.error);
707 let Some(BulkResultData::Ssl(ref report)) = r.data else {
708 panic!("expected Ssl data, got {:?}", r.data);
709 };
710 assert!(!report.chain.is_empty(), "chain must not be empty");
711 assert!(report.is_valid, "cloudflare leaf cert should be valid");
712 assert!(
713 report.days_until_expiry > 0,
714 "cert should still be valid in the future, got {} days",
715 report.days_until_expiry
716 );
717 assert!(
718 !report.san_names.is_empty(),
719 "cloudflare cert should have SAN entries"
720 );
721 }
722}