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
22#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum BulkOperation {
26 Whois {
27 domain: String,
28 },
29 Rdap {
30 domain: String,
31 },
32 Dns {
33 domain: String,
34 record_type: RecordType,
35 },
36 Propagation {
37 domain: String,
38 record_type: RecordType,
39 },
40 Lookup {
41 domain: String,
42 },
43 Status {
44 domain: String,
45 },
46 Avail {
47 domain: String,
48 },
49 Info {
50 domain: String,
51 },
52 Ssl {
53 domain: String,
54 },
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(tag = "result_type", content = "data", rename_all = "snake_case")]
60pub enum BulkResultData {
61 Whois(WhoisResponse),
62 Rdap(Box<RdapResponse>),
63 Dns(Vec<DnsRecord>),
64 Propagation(PropagationResult),
65 Lookup(LookupResult),
66 Status(StatusResponse),
67 Avail(AvailabilityResult),
68 Info(crate::domain_info::DomainInfo),
69 Ssl(SslReport),
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BulkResult {
75 pub operation: BulkOperation,
76 pub success: bool,
77 pub data: Option<BulkResultData>,
78 pub error: Option<String>,
79 pub duration_ms: u64,
80}
81
82#[derive(Debug, Clone)]
84pub struct BulkExecutor {
85 concurrency: usize,
86 rate_limit_delay: Duration,
87 whois_client: WhoisClient,
88 rdap_client: RdapClient,
89 dns_resolver: DnsResolver,
90 propagation_checker: PropagationChecker,
91 smart_lookup: SmartLookup,
92 status_client: StatusClient,
93 availability_checker: AvailabilityChecker,
94 ssl_checker: SslChecker,
95}
96
97impl Default for BulkExecutor {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl BulkExecutor {
104 pub fn new() -> Self {
105 Self {
106 concurrency: 10,
107 rate_limit_delay: Duration::from_millis(100),
108 whois_client: WhoisClient::new(),
109 rdap_client: RdapClient::new(),
110 dns_resolver: DnsResolver::new(),
111 propagation_checker: PropagationChecker::new(),
112 smart_lookup: SmartLookup::new(),
113 status_client: StatusClient::new(),
114 availability_checker: AvailabilityChecker::new(),
115 ssl_checker: SslChecker::new(),
116 }
117 }
118
119 pub fn with_concurrency(mut self, concurrency: usize) -> Self {
120 self.concurrency = concurrency.clamp(1, 50);
121 self
122 }
123
124 pub fn with_rate_limit(mut self, delay: Duration) -> Self {
125 self.rate_limit_delay = delay;
126 self
127 }
128
129 #[instrument(skip(self, operations, progress), fields(count = operations.len(), concurrency = self.concurrency))]
130 pub async fn execute(
131 &self,
132 operations: Vec<BulkOperation>,
133 progress: Option<ProgressCallback>,
134 ) -> Vec<BulkResult> {
135 let total = operations.len();
136 let completed = Arc::new(AtomicUsize::new(0));
137
138 info!("bulk operation started");
139 debug!(
140 total = total,
141 concurrency = self.concurrency,
142 "Starting bulk execution"
143 );
144
145 let limiter = if self.rate_limit_delay.is_zero() {
158 None
159 } else {
160 Some(Arc::new(Mutex::new(None::<TokioInstant>)))
161 };
162 let rate_limit_delay = self.rate_limit_delay;
163
164 let results: Vec<BulkResult> = stream::iter(operations)
165 .map(|op| {
166 let completed = completed.clone();
167 let progress = progress.as_ref();
168 let limiter = limiter.clone();
169 let whois_client = &self.whois_client;
170 let rdap_client = &self.rdap_client;
171 let dns_resolver = &self.dns_resolver;
172 let propagation_checker = &self.propagation_checker;
173 let smart_lookup = &self.smart_lookup;
174 let status_client = &self.status_client;
175 let availability_checker = &self.availability_checker;
176 let ssl_checker = &self.ssl_checker;
177
178 async move {
179 if let Some(limiter) = &limiter {
183 let my_slot = {
184 let mut next = limiter.lock().await;
185 let now = TokioInstant::now();
186 let slot = match *next {
187 Some(prev) if prev > now => prev,
188 _ => now,
189 };
190 *next = Some(slot + rate_limit_delay);
191 slot
192 };
193 sleep_until(my_slot).await;
194 }
195
196 let start = std::time::Instant::now();
197 let result = execute_operation(
198 &op,
199 &Clients {
200 whois: whois_client,
201 rdap: rdap_client,
202 dns: dns_resolver,
203 propagation: propagation_checker,
204 lookup: smart_lookup,
205 status: status_client,
206 avail: availability_checker,
207 ssl: ssl_checker,
208 },
209 )
210 .await;
211 let duration_ms = start.elapsed().as_millis() as u64;
212
213 let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
214
215 if let Some(progress) = progress {
216 let desc = match &op {
217 BulkOperation::Whois { domain }
218 | BulkOperation::Rdap { domain }
219 | BulkOperation::Dns { domain, .. }
220 | BulkOperation::Propagation { domain, .. }
221 | BulkOperation::Lookup { domain }
222 | BulkOperation::Status { domain }
223 | BulkOperation::Avail { domain }
224 | BulkOperation::Info { domain }
225 | BulkOperation::Ssl { domain } => domain.as_str(),
226 };
227 progress(count, total, desc);
228 }
229
230 match result {
231 Ok(data) => BulkResult {
232 operation: op,
233 success: true,
234 data: Some(data),
235 error: None,
236 duration_ms,
237 },
238 Err(e) => {
239 debug!(error = %e, "Bulk operation failed");
240 BulkResult {
241 operation: op,
242 success: false,
243 data: None,
244 error: Some(e.sanitized_message()),
247 duration_ms,
248 }
249 }
250 }
251 }
252 })
253 .buffer_unordered(self.concurrency)
258 .collect()
259 .await;
260
261 let succeeded = results.iter().filter(|r| r.success).count();
262 let failed = results.iter().filter(|r| !r.success).count();
263 info!(
264 total = total,
265 succeeded = succeeded,
266 failed = failed,
267 "bulk operation completed"
268 );
269
270 results
271 }
272
273 pub async fn execute_whois(&self, domains: Vec<String>) -> Vec<BulkResult> {
274 let operations = domains
275 .into_iter()
276 .map(|domain| BulkOperation::Whois { domain })
277 .collect();
278 self.execute(operations, None).await
279 }
280
281 pub async fn execute_rdap(&self, domains: Vec<String>) -> Vec<BulkResult> {
282 let operations = domains
283 .into_iter()
284 .map(|domain| BulkOperation::Rdap { domain })
285 .collect();
286 self.execute(operations, None).await
287 }
288
289 pub async fn execute_dns(
290 &self,
291 domains: Vec<String>,
292 record_type: RecordType,
293 ) -> Vec<BulkResult> {
294 let operations = domains
295 .into_iter()
296 .map(|domain| BulkOperation::Dns {
297 domain,
298 record_type,
299 })
300 .collect();
301 self.execute(operations, None).await
302 }
303
304 pub async fn execute_propagation(
305 &self,
306 domains: Vec<String>,
307 record_type: RecordType,
308 ) -> Vec<BulkResult> {
309 let operations = domains
310 .into_iter()
311 .map(|domain| BulkOperation::Propagation {
312 domain,
313 record_type,
314 })
315 .collect();
316 self.execute(operations, None).await
317 }
318
319 pub async fn execute_lookup(&self, domains: Vec<String>) -> Vec<BulkResult> {
320 let operations = domains
321 .into_iter()
322 .map(|domain| BulkOperation::Lookup { domain })
323 .collect();
324 self.execute(operations, None).await
325 }
326
327 pub async fn execute_status(&self, domains: Vec<String>) -> Vec<BulkResult> {
328 let operations = domains
329 .into_iter()
330 .map(|domain| BulkOperation::Status { domain })
331 .collect();
332 self.execute(operations, None).await
333 }
334
335 pub async fn execute_avail(&self, domains: Vec<String>) -> Vec<BulkResult> {
336 let operations = domains
337 .into_iter()
338 .map(|domain| BulkOperation::Avail { domain })
339 .collect();
340 self.execute(operations, None).await
341 }
342
343 pub async fn execute_info(&self, domains: Vec<String>) -> Vec<BulkResult> {
344 let operations = domains
345 .into_iter()
346 .map(|domain| BulkOperation::Info { domain })
347 .collect();
348 self.execute(operations, None).await
349 }
350
351 pub async fn execute_ssl(&self, domains: Vec<String>) -> Vec<BulkResult> {
352 let operations = domains
353 .into_iter()
354 .map(|domain| BulkOperation::Ssl { domain })
355 .collect();
356 self.execute(operations, None).await
357 }
358}
359
360struct Clients<'a> {
361 whois: &'a WhoisClient,
362 rdap: &'a RdapClient,
363 dns: &'a DnsResolver,
364 propagation: &'a PropagationChecker,
365 lookup: &'a SmartLookup,
366 status: &'a StatusClient,
367 avail: &'a AvailabilityChecker,
368 ssl: &'a SslChecker,
369}
370
371async fn execute_operation(op: &BulkOperation, clients: &Clients<'_>) -> Result<BulkResultData> {
372 match op {
373 BulkOperation::Whois { domain } => {
374 let result = clients.whois.lookup(domain).await?;
375 Ok(BulkResultData::Whois(result))
376 }
377 BulkOperation::Rdap { domain } => {
378 let result = clients.rdap.lookup_domain(domain).await?;
379 Ok(BulkResultData::Rdap(Box::new(result)))
380 }
381 BulkOperation::Dns {
382 domain,
383 record_type,
384 } => {
385 let result = clients.dns.resolve(domain, *record_type, None).await?;
386 Ok(BulkResultData::Dns(result))
387 }
388 BulkOperation::Propagation {
389 domain,
390 record_type,
391 } => {
392 let result = clients.propagation.check(domain, *record_type).await?;
393 Ok(BulkResultData::Propagation(result))
394 }
395 BulkOperation::Lookup { domain } => {
396 let result = clients.lookup.lookup(domain).await?;
397 Ok(BulkResultData::Lookup(result))
398 }
399 BulkOperation::Status { domain } => {
400 let result = clients.status.check(domain).await?;
401 Ok(BulkResultData::Status(result))
402 }
403 BulkOperation::Avail { domain } => {
404 let result = clients.avail.check(domain).await?;
405 Ok(BulkResultData::Avail(result))
406 }
407 BulkOperation::Info { domain } => {
408 let result = clients.lookup.lookup(domain).await?;
409 Ok(BulkResultData::Info(
410 crate::domain_info::DomainInfo::from_lookup_result(&result),
411 ))
412 }
413 BulkOperation::Ssl { domain } => {
414 let result = clients.ssl.check(domain).await?;
415 Ok(BulkResultData::Ssl(result))
416 }
417 }
418}
419
420const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
422
423fn is_csv_header_row(first: &str) -> bool {
430 let first_col = first.split(',').next().unwrap_or(first).trim();
431 if first_col.contains('.') {
433 return false;
434 }
435 let label = first_col.to_lowercase();
436 HEADER_KEYWORDS.contains(&label.as_str())
437}
438
439pub fn parse_domains_from_file(content: &str) -> Vec<String> {
440 let mut domains: Vec<String> = content
441 .lines()
442 .map(|line| line.trim())
443 .filter(|line| !line.is_empty() && !line.starts_with('#'))
444 .map(|line| {
445 line.split(',').next().unwrap_or(line).trim().to_string()
447 })
448 .filter(|domain| domain.contains('.'))
449 .collect();
450
451 if domains
459 .first()
460 .map(|d| is_csv_header_row(d))
461 .unwrap_or(false)
462 {
463 domains.remove(0);
464 }
465
466 domains
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn test_parse_domains_from_file() {
475 let content = r#"
476# This is a comment
477example1.com
478google2.com
479 whitespace3.com
480invalid
481csv,format,example.org
482"#;
483
484 let domains = parse_domains_from_file(content);
485 assert_eq!(domains.len(), 3);
486 assert!(domains.contains(&"example1.com".to_string()));
487 assert!(domains.contains(&"google2.com".to_string()));
488 assert!(domains.contains(&"whitespace3.com".to_string()));
489 }
491
492 #[test]
493 fn test_parse_domains_skip_bare_header() {
494 let content = "domain\nexample.com\n";
496 let domains = parse_domains_from_file(content);
497 assert_eq!(domains, vec!["example.com"]);
498 }
499
500 #[test]
501 fn test_parse_domains_multi_column_csv_header_dropped() {
502 let content = "domain,status\ngoogle.com,live\n";
505 let domains = parse_domains_from_file(content);
506 assert_eq!(domains, vec!["google.com"]);
507 assert!(!domains.contains(&"domain".to_string()));
508 }
509
510 #[test]
511 fn first_domain_with_no_digits_is_kept() {
512 let input = "google.com\namazon.com\n";
515 let result = parse_domains_from_file(input);
516 assert_eq!(result, vec!["google.com", "amazon.com"]);
517 }
518
519 #[test]
520 fn header_row_named_hostname_is_dropped() {
521 let input = "hostname\nexample.com\n";
522 let result = parse_domains_from_file(input);
523 assert_eq!(result, vec!["example.com"]);
524 }
525
526 #[test]
527 fn domain_dot_com_is_not_dropped_as_header() {
528 let input = "domain.com\nexample.com\n";
530 let result = parse_domains_from_file(input);
531 assert_eq!(result, vec!["domain.com", "example.com"]);
532 }
533
534 #[test]
535 fn is_csv_header_row_detects_bare_keywords() {
536 assert!(is_csv_header_row("domain"));
537 assert!(is_csv_header_row("Hostname"));
538 assert!(is_csv_header_row("URL"));
539 assert!(is_csv_header_row("domain,status,notes"));
540 assert!(is_csv_header_row(" host "));
541 }
542
543 #[test]
544 fn is_csv_header_row_rejects_dotted_values() {
545 assert!(!is_csv_header_row("domain.com"));
546 assert!(!is_csv_header_row("google.com"));
547 assert!(!is_csv_header_row("host.name"));
548 }
549
550 #[test]
551 fn is_csv_header_row_rejects_non_keyword() {
552 assert!(!is_csv_header_row("example"));
553 assert!(!is_csv_header_row("mydata"));
554 }
555
556 #[tokio::test]
572 async fn rate_limiter_dispatches_in_parallel_not_serialized() {
573 use std::time::Instant;
574 let executor = BulkExecutor::new()
575 .with_concurrency(5)
576 .with_rate_limit(Duration::from_millis(50));
577
578 let start = Instant::now();
585 let domains = vec![
586 "seer-rl-1.invalid".to_string(),
587 "seer-rl-2.invalid".to_string(),
588 "seer-rl-3.invalid".to_string(),
589 "seer-rl-4.invalid".to_string(),
590 ];
591 let results = executor.execute_ssl(domains).await;
592 let elapsed = start.elapsed();
593
594 assert_eq!(results.len(), 4);
595 assert!(
599 elapsed < Duration::from_secs(2),
600 "rate-limited dispatch should run in parallel; took {:?}",
601 elapsed
602 );
603 }
604
605 #[tokio::test]
606 async fn execute_ssl_failure_path_for_unresolvable_host() {
607 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
611 let results = executor
612 .execute_ssl(vec!["seer-bulk-ssl-test.invalid".to_string()])
613 .await;
614 assert_eq!(results.len(), 1);
615 let r = &results[0];
616 assert!(!r.success, "expected failure, got success");
617 assert!(r.data.is_none(), "expected no data on failure");
618 let err = r.error.as_deref().unwrap_or("");
619 assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
620 assert!(
621 matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
622 "expected Ssl variant, got {:?}",
623 r.operation
624 );
625 }
626
627 #[tokio::test]
628 #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
629 async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
630 let executor = BulkExecutor::new();
631 let results = executor
632 .execute_ssl(vec!["cloudflare.com".to_string()])
633 .await;
634 assert_eq!(results.len(), 1);
635 let r = &results[0];
636 assert!(r.success, "expected success, got error: {:?}", r.error);
637 let Some(BulkResultData::Ssl(ref report)) = r.data else {
638 panic!("expected Ssl data, got {:?}", r.data);
639 };
640 assert!(!report.chain.is_empty(), "chain must not be empty");
641 assert!(report.is_valid, "cloudflare leaf cert should be valid");
642 assert!(
643 report.days_until_expiry > 0,
644 "cert should still be valid in the future, got {} days",
645 report.days_until_expiry
646 );
647 assert!(
648 !report.san_names.is_empty(),
649 "cloudflare cert should have SAN entries"
650 );
651 }
652}