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
272 async move {
273 if let Some(limiter) = &limiter {
277 limiter.wait().await;
278 }
279
280 let start = std::time::Instant::now();
281 let result = self.run_op(&op).await;
282 let duration_ms = start.elapsed().as_millis() as u64;
283
284 let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
285
286 if let Some(progress) = progress {
287 progress(count, total, op.domain());
288 }
289
290 let bulk_result = match result {
291 Ok(data) => BulkResult {
292 operation: op,
293 success: true,
294 data: Some(trim_result_data(data)),
295 error: None,
296 duration_ms,
297 },
298 Err(e) => {
299 debug!(error = %e, "Bulk operation failed");
300 BulkResult {
301 operation: op,
302 success: false,
303 data: None,
304 error: Some(e.sanitized_message()),
307 duration_ms,
308 }
309 }
310 };
311
312 if let Some(on_result) = on_result {
315 on_result(&bulk_result);
316 }
317
318 bulk_result
319 }
320 })
321 .buffer_unordered(self.concurrency)
326 .collect()
327 .await;
328
329 let succeeded = results.iter().filter(|r| r.success).count();
330 let failed = results.iter().filter(|r| !r.success).count();
331 info!(
332 total = total,
333 succeeded = succeeded,
334 failed = failed,
335 "bulk operation completed"
336 );
337
338 results
339 }
340
341 async fn run_op(&self, op: &BulkOperation) -> Result<BulkResultData> {
343 match op {
344 BulkOperation::Whois { domain } => {
345 let result = self.whois_client.lookup(domain).await?;
346 Ok(BulkResultData::Whois(result))
347 }
348 BulkOperation::Rdap { domain } => {
349 let result = self.rdap_client.lookup_domain(domain).await?;
350 Ok(BulkResultData::Rdap(Box::new(result)))
351 }
352 BulkOperation::Dns {
353 domain,
354 record_type,
355 } => {
356 let result = self
357 .dns_resolver
358 .resolve(domain, *record_type, self.nameserver.as_deref())
359 .await?;
360 Ok(BulkResultData::Dns(result))
361 }
362 BulkOperation::Propagation {
363 domain,
364 record_type,
365 } => {
366 let result = self.propagation_checker.check(domain, *record_type).await?;
367 Ok(BulkResultData::Propagation(result))
368 }
369 BulkOperation::Lookup { domain } => {
370 let result = self.smart_lookup.lookup(domain).await?;
371 Ok(BulkResultData::Lookup(result))
372 }
373 BulkOperation::Status { domain } => {
374 let result = self.status_client.check(domain).await?;
375 Ok(BulkResultData::Status(result))
376 }
377 BulkOperation::Avail { domain } => {
378 let result = self.availability_checker.check(domain).await?;
379 Ok(BulkResultData::Avail(result))
380 }
381 BulkOperation::Info { domain } => {
382 let result = self.smart_lookup.lookup(domain).await?;
383 Ok(BulkResultData::Info(
384 crate::domain_info::DomainInfo::from_lookup_result(&result),
385 ))
386 }
387 BulkOperation::Ssl { domain } => {
388 let result = self.ssl_checker.check(domain).await?;
389 Ok(BulkResultData::Ssl(result))
390 }
391 BulkOperation::Posture { domain } => {
392 let result =
393 crate::posture::lookup_email_posture(&self.dns_resolver, domain).await?;
394 Ok(BulkResultData::Posture(result))
395 }
396 BulkOperation::Confusables { domain } => {
397 let result = crate::confusables::find_confusables(
401 &self.smart_lookup,
402 domain,
403 self.concurrency,
404 )
405 .await?;
406 Ok(BulkResultData::Confusables(result))
407 }
408 BulkOperation::Caa { domain } => {
409 let domain = crate::validation::normalize_domain(domain)?;
414 let policy = crate::caa::lookup_caa(&self.dns_resolver, &domain).await;
415 Ok(BulkResultData::Caa(policy))
416 }
417 }
418 }
419
420 pub async fn execute_each(
439 &self,
440 domains: Vec<String>,
441 op: impl FnMut(String) -> BulkOperation,
442 ) -> Vec<BulkResult> {
443 self.execute(domains.into_iter().map(op).collect(), None)
444 .await
445 }
446}
447
448fn trim_result_data(data: BulkResultData) -> BulkResultData {
456 match data {
457 BulkResultData::Whois(mut whois) => {
458 crate::lookup::trim_whois_raw(&mut whois);
459 BulkResultData::Whois(whois)
460 }
461 BulkResultData::Lookup(result) => {
462 BulkResultData::Lookup(crate::lookup::trim_raw_response(result))
463 }
464 other => other,
465 }
466}
467
468struct SlotLimiter {
476 delay: Duration,
477 next: Mutex<Option<TokioInstant>>,
478}
479
480impl SlotLimiter {
481 fn new(delay: Duration) -> Self {
482 Self {
483 delay,
484 next: Mutex::new(None),
485 }
486 }
487
488 async fn wait(&self) {
490 let my_slot = {
491 let mut next = self.next.lock().await;
492 let now = TokioInstant::now();
493 let slot = match *next {
494 Some(prev) if prev > now => prev,
495 _ => now,
496 };
497 *next = Some(slot + self.delay);
498 slot
499 };
500 sleep_until(my_slot).await;
501 }
502}
503
504const HEADER_KEYWORDS: &[&str] = &["domain", "host", "hostname", "url", "name", "site", "fqdn"];
506
507fn is_csv_header_row(first: &str) -> bool {
514 let first_col = first.split(',').next().unwrap_or(first).trim();
515 if first_col.contains('.') {
517 return false;
518 }
519 let label = first_col.to_lowercase();
520 HEADER_KEYWORDS.contains(&label.as_str())
521}
522
523pub fn parse_domains_from_file(content: &str) -> Vec<String> {
524 let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
528 let mut domains: Vec<String> = content
529 .lines()
530 .map(|line| line.trim())
531 .filter(|line| !line.is_empty() && !line.starts_with('#'))
532 .map(|line| {
533 line.split(',')
537 .next()
538 .unwrap_or(line)
539 .trim()
540 .trim_matches('"')
541 .trim()
542 .to_string()
543 })
544 .filter(|domain| domain.contains('.'))
545 .collect();
546
547 if domains
555 .first()
556 .map(|d| is_csv_header_row(d))
557 .unwrap_or(false)
558 {
559 domains.remove(0);
560 }
561
562 domains
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_parse_domains_from_file() {
571 let content = r#"
572# This is a comment
573example1.com
574google2.com
575 whitespace3.com
576invalid
577csv,format,example.org
578"#;
579
580 let domains = parse_domains_from_file(content);
581 assert_eq!(domains.len(), 3);
582 assert!(domains.contains(&"example1.com".to_string()));
583 assert!(domains.contains(&"google2.com".to_string()));
584 assert!(domains.contains(&"whitespace3.com".to_string()));
585 }
587
588 #[test]
589 fn parse_domains_handles_bom_and_quoted_csv_fields() {
590 let content =
592 "\u{FEFF}# domains for example.com\n\"example.com\",\"Alice\"\n\"test.org\"\n";
593 assert_eq!(
594 parse_domains_from_file(content),
595 vec!["example.com", "test.org"]
596 );
597 assert_eq!(
599 parse_domains_from_file("\u{FEFF}example.com\n"),
600 vec!["example.com"]
601 );
602 }
603
604 #[test]
605 fn from_config_carries_the_configured_nameserver() {
606 let config = crate::config::SeerConfig {
607 nameserver: Some("9.9.9.9".to_string()),
608 ..Default::default()
609 };
610 assert_eq!(
611 BulkExecutor::from_config(&config).nameserver.as_deref(),
612 Some("9.9.9.9")
613 );
614 assert!(BulkExecutor::new().nameserver.is_none());
615 }
616
617 #[test]
618 fn test_parse_domains_skip_bare_header() {
619 let content = "domain\nexample.com\n";
621 let domains = parse_domains_from_file(content);
622 assert_eq!(domains, vec!["example.com"]);
623 }
624
625 #[test]
626 fn test_parse_domains_multi_column_csv_header_dropped() {
627 let content = "domain,status\ngoogle.com,live\n";
630 let domains = parse_domains_from_file(content);
631 assert_eq!(domains, vec!["google.com"]);
632 assert!(!domains.contains(&"domain".to_string()));
633 }
634
635 #[test]
636 fn first_domain_with_no_digits_is_kept() {
637 let input = "google.com\namazon.com\n";
640 let result = parse_domains_from_file(input);
641 assert_eq!(result, vec!["google.com", "amazon.com"]);
642 }
643
644 #[test]
645 fn header_row_named_hostname_is_dropped() {
646 let input = "hostname\nexample.com\n";
647 let result = parse_domains_from_file(input);
648 assert_eq!(result, vec!["example.com"]);
649 }
650
651 #[test]
652 fn domain_dot_com_is_not_dropped_as_header() {
653 let input = "domain.com\nexample.com\n";
655 let result = parse_domains_from_file(input);
656 assert_eq!(result, vec!["domain.com", "example.com"]);
657 }
658
659 #[test]
660 fn is_csv_header_row_detects_bare_keywords() {
661 assert!(is_csv_header_row("domain"));
662 assert!(is_csv_header_row("Hostname"));
663 assert!(is_csv_header_row("URL"));
664 assert!(is_csv_header_row("domain,status,notes"));
665 assert!(is_csv_header_row(" host "));
666 }
667
668 #[test]
669 fn is_csv_header_row_rejects_dotted_values() {
670 assert!(!is_csv_header_row("domain.com"));
671 assert!(!is_csv_header_row("google.com"));
672 assert!(!is_csv_header_row("host.name"));
673 }
674
675 #[test]
676 fn is_csv_header_row_rejects_non_keyword() {
677 assert!(!is_csv_header_row("example"));
678 assert!(!is_csv_header_row("mydata"));
679 }
680
681 #[test]
682 fn bulk_operation_domain_returns_domain_for_every_variant() {
683 let d = "example.com".to_string();
684 let ops = vec![
685 BulkOperation::Whois { domain: d.clone() },
686 BulkOperation::Rdap { domain: d.clone() },
687 BulkOperation::Dns {
688 domain: d.clone(),
689 record_type: RecordType::A,
690 },
691 BulkOperation::Propagation {
692 domain: d.clone(),
693 record_type: RecordType::A,
694 },
695 BulkOperation::Lookup { domain: d.clone() },
696 BulkOperation::Status { domain: d.clone() },
697 BulkOperation::Avail { domain: d.clone() },
698 BulkOperation::Info { domain: d.clone() },
699 BulkOperation::Ssl { domain: d.clone() },
700 BulkOperation::Posture { domain: d.clone() },
701 BulkOperation::Confusables { domain: d.clone() },
702 BulkOperation::Caa { domain: d.clone() },
703 ];
704 for op in &ops {
705 assert_eq!(op.domain(), "example.com", "variant {:?}", op);
706 }
707 }
708
709 #[test]
712 fn new_bulk_variants_serialize_with_snake_case_tags() {
713 let op = BulkOperation::Posture {
714 domain: "example.com".to_string(),
715 };
716 let json = serde_json::to_string(&op).expect("serialize Posture op");
717 assert!(json.contains(r#""type":"posture""#), "got: {json}");
718
719 let op = BulkOperation::Confusables {
720 domain: "example.com".to_string(),
721 };
722 let json = serde_json::to_string(&op).expect("serialize Confusables op");
723 assert!(json.contains(r#""type":"confusables""#), "got: {json}");
724
725 let op = BulkOperation::Caa {
726 domain: "example.com".to_string(),
727 };
728 let json = serde_json::to_string(&op).expect("serialize Caa op");
729 assert!(json.contains(r#""type":"caa""#), "got: {json}");
730
731 let data = BulkResultData::Caa(CaaPolicy::empty());
732 let json = serde_json::to_string(&data).expect("serialize Caa result");
733 assert!(json.contains(r#""result_type":"caa""#), "got: {json}");
734 }
735
736 #[tokio::test]
740 async fn new_bulk_arms_fail_cleanly_on_invalid_domain() {
741 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
742 let bad = || "not a domain".to_string();
743
744 for (name, op) in [
745 ("posture", BulkOperation::Posture { domain: bad() }),
746 ("confusables", BulkOperation::Confusables { domain: bad() }),
747 ("caa", BulkOperation::Caa { domain: bad() }),
748 ] {
749 let results = executor.execute(vec![op], None).await;
750 assert_eq!(results.len(), 1, "{name}: expected one result");
751 let r = &results[0];
752 assert!(!r.success, "{name}: expected failure for invalid domain");
753 assert!(r.data.is_none(), "{name}: expected no data on failure");
754 let err = r.error.as_deref().unwrap_or("");
755 assert!(!err.is_empty(), "{name}: expected non-empty error");
756 assert_eq!(r.operation.domain(), "not a domain", "{name}");
757 }
758 }
759
760 #[test]
763 fn from_config_applies_bulk_settings() {
764 let mut config = crate::config::SeerConfig::default();
765 config.bulk.concurrency = 25;
766 config.bulk.rate_limit_ms = 250;
767
768 let executor = BulkExecutor::from_config(&config);
769 assert_eq!(executor.concurrency, 25);
770 assert_eq!(executor.rate_limit_delay, Duration::from_millis(250));
771
772 config.bulk.concurrency = 500;
775 let executor = BulkExecutor::from_config(&config);
776 assert_eq!(executor.concurrency, 50);
777 }
778
779 #[test]
783 fn bulk_whois_oversized_raw_response_is_truncated() {
784 const MAX_RAW: usize = 32 * 1024;
785 let raw = "a".repeat(MAX_RAW + 1000);
786 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
787 assert!(whois.raw_response.len() > MAX_RAW);
788
789 let trimmed = trim_result_data(BulkResultData::Whois(whois));
790 let BulkResultData::Whois(whois) = trimmed else {
791 panic!("expected Whois variant");
792 };
793 assert!(
794 whois.raw_response.len() <= MAX_RAW + "\n... [truncated]".len(),
795 "raw_response not bounded: {} bytes",
796 whois.raw_response.len()
797 );
798 assert!(whois.raw_response.ends_with("[truncated]"));
799 }
800
801 #[test]
802 fn bulk_lookup_oversized_raw_response_is_truncated() {
803 const MAX_RAW: usize = 32 * 1024;
804 let raw = "a".repeat(MAX_RAW + 1000);
805 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
806
807 let trimmed = trim_result_data(BulkResultData::Lookup(LookupResult::Whois {
808 data: whois,
809 rdap_error: None,
810 rdap_fallback: None,
811 }));
812 let BulkResultData::Lookup(LookupResult::Whois { data, .. }) = trimmed else {
813 panic!("expected Lookup(Whois) variant");
814 };
815 assert!(data.raw_response.len() <= MAX_RAW + "\n... [truncated]".len());
816 assert!(data.raw_response.ends_with("[truncated]"));
817 }
818
819 #[tokio::test(start_paused = true)]
828 async fn rate_limiter_dispatches_in_parallel_not_serialized() {
829 let limiter = Arc::new(SlotLimiter::new(Duration::from_millis(50)));
830 let start = TokioInstant::now();
831 let slots: Vec<Duration> = stream::iter(0..4)
832 .map(|_| {
833 let limiter = limiter.clone();
834 async move {
835 limiter.wait().await;
836 let slot = start.elapsed();
837 tokio::time::sleep(Duration::from_millis(500)).await;
838 slot
839 }
840 })
841 .buffer_unordered(4)
842 .collect()
843 .await;
844 let elapsed = start.elapsed();
845
846 let mut slots = slots;
847 slots.sort();
848 assert_eq!(
849 slots,
850 [0, 50, 100, 150].map(Duration::from_millis),
851 "dispatches must be spaced exactly one delay apart"
852 );
853 assert_eq!(
854 elapsed,
855 Duration::from_millis(650),
856 "slow operations must overlap, not run one after another"
857 );
858 }
859
860 #[tokio::test]
861 async fn execute_ssl_failure_path_for_unresolvable_host() {
862 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
866 let results = executor
867 .execute_each(vec!["seer-bulk-ssl-test.invalid".to_string()], |domain| {
868 BulkOperation::Ssl { domain }
869 })
870 .await;
871 assert_eq!(results.len(), 1);
872 let r = &results[0];
873 assert!(!r.success, "expected failure, got success");
874 assert!(r.data.is_none(), "expected no data on failure");
875 let err = r.error.as_deref().unwrap_or("");
876 assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
877 assert!(
878 matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
879 "expected Ssl variant, got {:?}",
880 r.operation
881 );
882 }
883
884 #[tokio::test]
885 async fn execute_streaming_invokes_callback_per_result() {
886 use std::sync::atomic::AtomicUsize;
887 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
891 let seen = Arc::new(AtomicUsize::new(0));
892 let seen_cb = seen.clone();
893 let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
894 seen_cb.fetch_add(1, Ordering::Relaxed);
895 });
896 let operations = vec![
897 BulkOperation::Avail {
898 domain: "seer-stream-1.invalid".to_string(),
899 },
900 BulkOperation::Avail {
901 domain: "seer-stream-2.invalid".to_string(),
902 },
903 BulkOperation::Avail {
904 domain: "seer-stream-3.invalid".to_string(),
905 },
906 ];
907 let results = executor.execute_streaming(operations, cb).await;
908 assert_eq!(results.len(), 3, "all operations should be returned");
909 assert_eq!(
910 seen.load(Ordering::Relaxed),
911 3,
912 "callback must fire exactly once per result"
913 );
914 }
915
916 #[tokio::test]
917 #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
918 async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
919 let executor = BulkExecutor::new();
920 let results = executor
921 .execute_each(vec!["cloudflare.com".to_string()], |domain| {
922 BulkOperation::Ssl { domain }
923 })
924 .await;
925 assert_eq!(results.len(), 1);
926 let r = &results[0];
927 assert!(r.success, "expected success, got error: {:?}", r.error);
928 let Some(BulkResultData::Ssl(ref report)) = r.data else {
929 panic!("expected Ssl data, got {:?}", r.data);
930 };
931 assert!(!report.chain.is_empty(), "chain must not be empty");
932 assert!(report.is_valid, "cloudflare leaf cert should be valid");
933 assert!(
934 report.days_until_expiry > 0,
935 "cert should still be valid in the future, got {} days",
936 report.days_until_expiry
937 );
938 assert!(
939 !report.san_names.is_empty(),
940 "cloudflare cert should have SAN entries"
941 );
942 }
943}