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 parallel = self.concurrency.min(total);
267
268 let results: Vec<BulkResult> = stream::iter(operations)
269 .map(|op| {
270 let completed = completed.clone();
271 let progress = progress.as_ref();
272 let on_result = on_result.as_ref();
273 let limiter = limiter.clone();
274
275 async move {
276 if let Some(limiter) = &limiter {
280 limiter.wait().await;
281 }
282
283 let start = std::time::Instant::now();
284 let result = self.run_op(&op, parallel).await;
285 let duration_ms = start.elapsed().as_millis() as u64;
286
287 let count = completed.fetch_add(1, Ordering::Relaxed) + 1;
288
289 if let Some(progress) = progress {
290 progress(count, total, op.domain());
291 }
292
293 let bulk_result = match result {
294 Ok(data) => BulkResult {
295 operation: op,
296 success: true,
297 data: Some(trim_result_data(data)),
298 error: None,
299 duration_ms,
300 },
301 Err(e) => {
302 debug!(error = %e, "Bulk operation failed");
303 BulkResult {
304 operation: op,
305 success: false,
306 data: None,
307 error: Some(e.sanitized_message()),
310 duration_ms,
311 }
312 }
313 };
314
315 if let Some(on_result) = on_result {
318 on_result(&bulk_result);
319 }
320
321 bulk_result
322 }
323 })
324 .buffer_unordered(self.concurrency)
329 .collect()
330 .await;
331
332 let succeeded = results.iter().filter(|r| r.success).count();
333 let failed = results.iter().filter(|r| !r.success).count();
334 info!(
335 total = total,
336 succeeded = succeeded,
337 failed = failed,
338 "bulk operation completed"
339 );
340
341 results
342 }
343
344 async fn run_op(&self, op: &BulkOperation, parallel: usize) -> Result<BulkResultData> {
347 match op {
348 BulkOperation::Whois { domain } => {
349 let result = self.whois_client.lookup(domain).await?;
350 Ok(BulkResultData::Whois(result))
351 }
352 BulkOperation::Rdap { domain } => {
353 let result = self.rdap_client.lookup_domain(domain).await?;
354 Ok(BulkResultData::Rdap(Box::new(result)))
355 }
356 BulkOperation::Dns {
357 domain,
358 record_type,
359 } => {
360 let result = self
361 .dns_resolver
362 .resolve(domain, *record_type, self.nameserver.as_deref())
363 .await?;
364 Ok(BulkResultData::Dns(result))
365 }
366 BulkOperation::Propagation {
367 domain,
368 record_type,
369 } => {
370 let result = self.propagation_checker.check(domain, *record_type).await?;
371 Ok(BulkResultData::Propagation(result))
372 }
373 BulkOperation::Lookup { domain } => {
374 let result = self.smart_lookup.lookup(domain).await?;
375 Ok(BulkResultData::Lookup(result))
376 }
377 BulkOperation::Status { domain } => {
378 let result = self.status_client.check(domain).await?;
379 Ok(BulkResultData::Status(result))
380 }
381 BulkOperation::Avail { domain } => {
382 let result = self.availability_checker.check(domain).await?;
383 Ok(BulkResultData::Avail(result))
384 }
385 BulkOperation::Info { domain } => {
386 let result = self.smart_lookup.lookup(domain).await?;
387 Ok(BulkResultData::Info(
388 crate::domain_info::DomainInfo::from_lookup_result(&result),
389 ))
390 }
391 BulkOperation::Ssl { domain } => {
392 let result = self.ssl_checker.check(domain).await?;
393 Ok(BulkResultData::Ssl(result))
394 }
395 BulkOperation::Posture { domain } => {
396 let result = crate::posture::lookup_email_posture(
397 &self.dns_resolver,
398 domain,
399 self.nameserver.as_deref(),
400 )
401 .await?;
402 Ok(BulkResultData::Posture(result))
403 }
404 BulkOperation::Confusables { domain } => {
405 let result = crate::confusables::find_confusables_shared(
409 &self.smart_lookup,
410 domain,
411 self.concurrency,
412 parallel,
413 )
414 .await?;
415 Ok(BulkResultData::Confusables(result))
416 }
417 BulkOperation::Caa { domain } => {
418 let policy =
421 crate::caa::lookup_caa(&self.dns_resolver, domain, self.nameserver.as_deref())
422 .await?;
423 Ok(BulkResultData::Caa(policy))
424 }
425 }
426 }
427
428 pub async fn execute_each(
447 &self,
448 domains: Vec<String>,
449 op: impl FnMut(String) -> BulkOperation,
450 ) -> Vec<BulkResult> {
451 self.execute(domains.into_iter().map(op).collect(), None)
452 .await
453 }
454}
455
456fn trim_result_data(data: BulkResultData) -> BulkResultData {
464 match data {
465 BulkResultData::Whois(mut whois) => {
466 crate::lookup::trim_whois_raw(&mut whois);
467 BulkResultData::Whois(whois)
468 }
469 BulkResultData::Lookup(result) => {
470 BulkResultData::Lookup(crate::lookup::trim_raw_response(result))
471 }
472 other => other,
473 }
474}
475
476struct SlotLimiter {
484 delay: Duration,
485 next: Mutex<Option<TokioInstant>>,
486}
487
488impl SlotLimiter {
489 fn new(delay: Duration) -> Self {
490 Self {
491 delay,
492 next: Mutex::new(None),
493 }
494 }
495
496 async fn wait(&self) {
498 let my_slot = {
499 let mut next = self.next.lock().await;
500 let now = TokioInstant::now();
501 let slot = match *next {
502 Some(prev) if prev > now => prev,
503 _ => now,
504 };
505 *next = Some(slot + self.delay);
506 slot
507 };
508 sleep_until(my_slot).await;
509 }
510}
511
512pub fn parse_domains_from_file(content: &str) -> Vec<String> {
517 let content = content.strip_prefix('\u{FEFF}').unwrap_or(content);
521 content
522 .lines()
523 .map(|line| line.trim())
524 .filter(|line| !line.is_empty() && !line.starts_with('#'))
525 .map(|line| {
526 line.split(',')
530 .next()
531 .unwrap_or(line)
532 .trim()
533 .trim_matches('"')
534 .trim()
535 .to_string()
536 })
537 .filter(|domain| domain.contains('.'))
538 .collect()
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_parse_domains_from_file() {
547 let content = r#"
548# This is a comment
549example1.com
550google2.com
551 whitespace3.com
552invalid
553csv,format,example.org
554"#;
555
556 let domains = parse_domains_from_file(content);
557 assert_eq!(domains.len(), 3);
558 assert!(domains.contains(&"example1.com".to_string()));
559 assert!(domains.contains(&"google2.com".to_string()));
560 assert!(domains.contains(&"whitespace3.com".to_string()));
561 }
563
564 #[test]
565 fn parse_domains_handles_bom_and_quoted_csv_fields() {
566 let content =
568 "\u{FEFF}# domains for example.com\n\"example.com\",\"Alice\"\n\"test.org\"\n";
569 assert_eq!(
570 parse_domains_from_file(content),
571 vec!["example.com", "test.org"]
572 );
573 assert_eq!(
575 parse_domains_from_file("\u{FEFF}example.com\n"),
576 vec!["example.com"]
577 );
578 }
579
580 #[test]
581 fn from_config_carries_the_configured_nameserver() {
582 let config = crate::config::SeerConfig {
583 nameserver: Some("9.9.9.9".to_string()),
584 ..Default::default()
585 };
586 assert_eq!(
587 BulkExecutor::from_config(&config).nameserver.as_deref(),
588 Some("9.9.9.9")
589 );
590 assert!(BulkExecutor::new().nameserver.is_none());
591 }
592
593 #[test]
594 fn test_parse_domains_skip_bare_header() {
595 let content = "domain\nexample.com\n";
597 let domains = parse_domains_from_file(content);
598 assert_eq!(domains, vec!["example.com"]);
599 }
600
601 #[test]
602 fn test_parse_domains_multi_column_csv_header_dropped() {
603 let content = "domain,status\ngoogle.com,live\n";
606 let domains = parse_domains_from_file(content);
607 assert_eq!(domains, vec!["google.com"]);
608 assert!(!domains.contains(&"domain".to_string()));
609 }
610
611 #[test]
612 fn first_domain_with_no_digits_is_kept() {
613 let input = "google.com\namazon.com\n";
616 let result = parse_domains_from_file(input);
617 assert_eq!(result, vec!["google.com", "amazon.com"]);
618 }
619
620 #[test]
621 fn header_row_named_hostname_is_dropped() {
622 let input = "hostname\nexample.com\n";
623 let result = parse_domains_from_file(input);
624 assert_eq!(result, vec!["example.com"]);
625 }
626
627 #[test]
628 fn domain_dot_com_is_not_dropped_as_header() {
629 let input = "domain.com\nexample.com\n";
631 let result = parse_domains_from_file(input);
632 assert_eq!(result, vec!["domain.com", "example.com"]);
633 }
634
635 #[test]
636 fn bulk_operation_domain_returns_domain_for_every_variant() {
637 let d = "example.com".to_string();
638 let ops = vec![
639 BulkOperation::Whois { domain: d.clone() },
640 BulkOperation::Rdap { domain: d.clone() },
641 BulkOperation::Dns {
642 domain: d.clone(),
643 record_type: RecordType::A,
644 },
645 BulkOperation::Propagation {
646 domain: d.clone(),
647 record_type: RecordType::A,
648 },
649 BulkOperation::Lookup { domain: d.clone() },
650 BulkOperation::Status { domain: d.clone() },
651 BulkOperation::Avail { domain: d.clone() },
652 BulkOperation::Info { domain: d.clone() },
653 BulkOperation::Ssl { domain: d.clone() },
654 BulkOperation::Posture { domain: d.clone() },
655 BulkOperation::Confusables { domain: d.clone() },
656 BulkOperation::Caa { domain: d.clone() },
657 ];
658 for op in &ops {
659 assert_eq!(op.domain(), "example.com", "variant {:?}", op);
660 }
661 }
662
663 #[test]
666 fn new_bulk_variants_serialize_with_snake_case_tags() {
667 let op = BulkOperation::Posture {
668 domain: "example.com".to_string(),
669 };
670 let json = serde_json::to_string(&op).expect("serialize Posture op");
671 assert!(json.contains(r#""type":"posture""#), "got: {json}");
672
673 let op = BulkOperation::Confusables {
674 domain: "example.com".to_string(),
675 };
676 let json = serde_json::to_string(&op).expect("serialize Confusables op");
677 assert!(json.contains(r#""type":"confusables""#), "got: {json}");
678
679 let op = BulkOperation::Caa {
680 domain: "example.com".to_string(),
681 };
682 let json = serde_json::to_string(&op).expect("serialize Caa op");
683 assert!(json.contains(r#""type":"caa""#), "got: {json}");
684
685 let data = BulkResultData::Caa(CaaPolicy::empty());
686 let json = serde_json::to_string(&data).expect("serialize Caa result");
687 assert!(json.contains(r#""result_type":"caa""#), "got: {json}");
688 }
689
690 #[tokio::test]
694 async fn new_bulk_arms_fail_cleanly_on_invalid_domain() {
695 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
696 let bad = || "not a domain".to_string();
697
698 for (name, op) in [
699 ("posture", BulkOperation::Posture { domain: bad() }),
700 ("confusables", BulkOperation::Confusables { domain: bad() }),
701 ("caa", BulkOperation::Caa { domain: bad() }),
702 ] {
703 let results = executor.execute(vec![op], None).await;
704 assert_eq!(results.len(), 1, "{name}: expected one result");
705 let r = &results[0];
706 assert!(!r.success, "{name}: expected failure for invalid domain");
707 assert!(r.data.is_none(), "{name}: expected no data on failure");
708 let err = r.error.as_deref().unwrap_or("");
709 assert!(!err.is_empty(), "{name}: expected non-empty error");
710 assert_eq!(r.operation.domain(), "not a domain", "{name}");
711 }
712 }
713
714 #[test]
717 fn from_config_applies_bulk_settings() {
718 let mut config = crate::config::SeerConfig::default();
719 config.bulk.concurrency = 25;
720 config.bulk.rate_limit_ms = 250;
721
722 let executor = BulkExecutor::from_config(&config);
723 assert_eq!(executor.concurrency, 25);
724 assert_eq!(executor.rate_limit_delay, Duration::from_millis(250));
725
726 config.bulk.concurrency = 500;
729 let executor = BulkExecutor::from_config(&config);
730 assert_eq!(executor.concurrency, 50);
731 }
732
733 #[test]
737 fn bulk_whois_oversized_raw_response_is_truncated() {
738 const MAX_RAW: usize = 32 * 1024;
739 let raw = "a".repeat(MAX_RAW + 1000);
740 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
741 assert!(whois.raw_response.len() > MAX_RAW);
742
743 let trimmed = trim_result_data(BulkResultData::Whois(whois));
744 let BulkResultData::Whois(whois) = trimmed else {
745 panic!("expected Whois variant");
746 };
747 assert!(
748 whois.raw_response.len() <= MAX_RAW + "\n... [truncated]".len(),
749 "raw_response not bounded: {} bytes",
750 whois.raw_response.len()
751 );
752 assert!(whois.raw_response.ends_with("[truncated]"));
753 }
754
755 #[test]
756 fn bulk_lookup_oversized_raw_response_is_truncated() {
757 const MAX_RAW: usize = 32 * 1024;
758 let raw = "a".repeat(MAX_RAW + 1000);
759 let whois = WhoisResponse::parse("example.com", "whois.example.com", &raw);
760
761 let trimmed = trim_result_data(BulkResultData::Lookup(LookupResult::Whois {
762 data: whois,
763 rdap_error: None,
764 rdap_fallback: None,
765 dns_nameservers: None,
766 }));
767 let BulkResultData::Lookup(LookupResult::Whois { data, .. }) = trimmed else {
768 panic!("expected Lookup(Whois) variant");
769 };
770 assert!(data.raw_response.len() <= MAX_RAW + "\n... [truncated]".len());
771 assert!(data.raw_response.ends_with("[truncated]"));
772 }
773
774 #[tokio::test(start_paused = true)]
783 async fn rate_limiter_dispatches_in_parallel_not_serialized() {
784 let limiter = Arc::new(SlotLimiter::new(Duration::from_millis(50)));
785 let start = TokioInstant::now();
786 let slots: Vec<Duration> = stream::iter(0..4)
787 .map(|_| {
788 let limiter = limiter.clone();
789 async move {
790 limiter.wait().await;
791 let slot = start.elapsed();
792 tokio::time::sleep(Duration::from_millis(500)).await;
793 slot
794 }
795 })
796 .buffer_unordered(4)
797 .collect()
798 .await;
799 let elapsed = start.elapsed();
800
801 let mut slots = slots;
802 slots.sort();
803 assert_eq!(
804 slots,
805 [0, 50, 100, 150].map(Duration::from_millis),
806 "dispatches must be spaced exactly one delay apart"
807 );
808 assert_eq!(
809 elapsed,
810 Duration::from_millis(650),
811 "slow operations must overlap, not run one after another"
812 );
813 }
814
815 #[tokio::test]
816 async fn execute_ssl_failure_path_for_unresolvable_host() {
817 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
821 let results = executor
822 .execute_each(vec!["seer-bulk-ssl-test.invalid".to_string()], |domain| {
823 BulkOperation::Ssl { domain }
824 })
825 .await;
826 assert_eq!(results.len(), 1);
827 let r = &results[0];
828 assert!(!r.success, "expected failure, got success");
829 assert!(r.data.is_none(), "expected no data on failure");
830 let err = r.error.as_deref().unwrap_or("");
831 assert!(!err.is_empty(), "expected non-empty error, got: {:?}", err);
832 assert!(
833 matches!(r.operation, BulkOperation::Ssl { ref domain } if domain == "seer-bulk-ssl-test.invalid"),
834 "expected Ssl variant, got {:?}",
835 r.operation
836 );
837 }
838
839 #[tokio::test]
840 async fn execute_streaming_invokes_callback_per_result() {
841 use std::sync::atomic::AtomicUsize;
842 let executor = BulkExecutor::new().with_rate_limit(Duration::ZERO);
846 let seen = Arc::new(AtomicUsize::new(0));
847 let seen_cb = seen.clone();
848 let cb: ResultCallback = Box::new(move |_r: &BulkResult| {
849 seen_cb.fetch_add(1, Ordering::Relaxed);
850 });
851 let operations = vec![
852 BulkOperation::Avail {
853 domain: "seer-stream-1.invalid".to_string(),
854 },
855 BulkOperation::Avail {
856 domain: "seer-stream-2.invalid".to_string(),
857 },
858 BulkOperation::Avail {
859 domain: "seer-stream-3.invalid".to_string(),
860 },
861 ];
862 let results = executor.execute_streaming(operations, cb).await;
863 assert_eq!(results.len(), 3, "all operations should be returned");
864 assert_eq!(
865 seen.load(Ordering::Relaxed),
866 3,
867 "callback must fire exactly once per result"
868 );
869 }
870
871 #[tokio::test]
872 #[ignore = "live network: hits cloudflare.com:443; run with --ignored"]
873 async fn execute_ssl_live_cloudflare_has_non_empty_chain() {
874 let executor = BulkExecutor::new();
875 let results = executor
876 .execute_each(vec!["cloudflare.com".to_string()], |domain| {
877 BulkOperation::Ssl { domain }
878 })
879 .await;
880 assert_eq!(results.len(), 1);
881 let r = &results[0];
882 assert!(r.success, "expected success, got error: {:?}", r.error);
883 let Some(BulkResultData::Ssl(ref report)) = r.data else {
884 panic!("expected Ssl data, got {:?}", r.data);
885 };
886 assert!(!report.chain.is_empty(), "chain must not be empty");
887 assert!(report.is_valid, "cloudflare leaf cert should be valid");
888 assert!(
889 report.days_until_expiry > 0,
890 "cert should still be valid in the future, got {} days",
891 report.days_until_expiry
892 );
893 assert!(
894 !report.san_names.is_empty(),
895 "cloudflare cert should have SAN entries"
896 );
897 }
898}