1use super::{Macro, Mechanism, Qualifier, Spf, Variables};
8use crate::{
9 Error, MX, MessageAuthenticator, Parameters, ResolverCache, SpfOutput, SpfResult, Txt,
10 common::cache::NoCache,
11};
12use std::{
13 net::{IpAddr, Ipv4Addr, Ipv6Addr},
14 sync::Arc,
15 time::Instant,
16};
17
18pub struct SpfParameters<'x> {
19 ip: IpAddr,
20 domain: &'x str,
21 helo_domain: &'x str,
22 host_domain: &'x str,
23 sender: Sender<'x>,
24}
25
26enum Sender<'x> {
27 Ehlo(String),
28 MailFrom(&'x str),
29 Full(&'x str),
30}
31
32#[allow(clippy::iter_skip_zero)]
33impl MessageAuthenticator {
34 pub async fn verify_spf<'x, TXT, MXX, IPV4, IPV6, PTR>(
36 &self,
37 params: impl Into<Parameters<'x, SpfParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
38 ) -> SpfOutput
39 where
40 TXT: ResolverCache<String, Txt> + 'x,
41 MXX: ResolverCache<String, Arc<Vec<MX>>> + 'x,
42 IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>> + 'x,
43 IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>> + 'x,
44 PTR: ResolverCache<IpAddr, Arc<Vec<String>>> + 'x,
45 {
46 let params = params.into();
47 match ¶ms.params.sender {
48 Sender::Full(sender) => {
49 let output = self
51 .check_host(params.clone_with(SpfParameters::verify_ehlo(
52 params.params.ip,
53 params.params.helo_domain,
54 params.params.host_domain,
55 )))
56 .await;
57 if matches!(output.result(), SpfResult::Pass) {
58 self.check_host(params.clone_with(SpfParameters::verify_mail_from(
60 params.params.ip,
61 params.params.helo_domain,
62 params.params.host_domain,
63 sender,
64 )))
65 .await
66 } else {
67 output
68 }
69 }
70 _ => self.check_host(params).await,
71 }
72 }
73
74 #[allow(clippy::while_let_on_iterator)]
75 #[allow(clippy::iter_skip_zero)]
76 pub async fn check_host<'x, TXT, MXX, IPV4, IPV6, PTR>(
77 &self,
78 params: Parameters<'x, SpfParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>,
79 ) -> SpfOutput
80 where
81 TXT: ResolverCache<String, Txt>,
82 MXX: ResolverCache<String, Arc<Vec<MX>>>,
83 IPV4: ResolverCache<String, Arc<Vec<Ipv4Addr>>>,
84 IPV6: ResolverCache<String, Arc<Vec<Ipv6Addr>>>,
85 PTR: ResolverCache<IpAddr, Arc<Vec<String>>>,
86 {
87 let domain = params.params.domain;
88 let ip = params.params.ip;
89 let helo_domain = params.params.helo_domain;
90 let host_domain = params.params.host_domain;
91 let sender = match ¶ms.params.sender {
92 Sender::Ehlo(sender) => sender.as_str(),
93 Sender::MailFrom(sender) => sender,
94 Sender::Full(sender) => sender,
95 };
96
97 let output = SpfOutput::new(domain.to_string());
98 if domain.is_empty() || domain.len() > 255 || !domain.has_valid_labels() {
99 return output.with_result(SpfResult::None);
100 }
101 let mut vars = Variables::new();
102 let mut has_p_var = false;
103 vars.set_ip(&ip);
104 if !sender.is_empty() {
105 vars.set_sender(sender.as_bytes());
106 } else {
107 vars.set_sender(format!("postmaster@{domain}").into_bytes());
108 }
109 vars.set_domain(domain.as_bytes());
110 vars.set_host_domain(host_domain.as_bytes());
111 vars.set_helo_domain(helo_domain.as_bytes());
112
113 let mut lookup_limit = LookupLimit::new();
114 let mut spf_record = match self.txt_lookup::<Spf>(domain, params.cache_txt).await {
115 Ok(spf_record) => spf_record,
116 Err(err) => return output.with_result(err.into()),
117 };
118
119 let mut domain = domain.to_string();
120 let mut include_stack = Vec::new();
121
122 let mut result = None;
123 let mut directives = spf_record.directives.iter().enumerate().skip(0);
124
125 loop {
126 while let Some((pos, directive)) = directives.next() {
127 if !has_p_var && directive.mechanism.needs_ptr() {
128 if !lookup_limit.can_lookup() {
129 return output
130 .with_result(SpfResult::PermError)
131 .with_report(&spf_record);
132 }
133 if let Some(ptr) = self
134 .ptr_lookup(ip, params.cache_ptr)
135 .await
136 .ok()
137 .and_then(|ptrs| ptrs.first().map(|ptr| ptr.as_bytes().to_vec()))
138 {
139 vars.set_validated_domain(ptr);
140 }
141 has_p_var = true;
142 }
143
144 let matches = match &directive.mechanism {
145 Mechanism::All => true,
146 Mechanism::Ip4 { addr, mask } => ip.matches_ipv4_mask(addr, *mask),
147 Mechanism::Ip6 { addr, mask } => ip.matches_ipv6_mask(addr, *mask),
148 Mechanism::A {
149 macro_string,
150 ip4_mask,
151 ip6_mask,
152 } => {
153 if !lookup_limit.can_lookup() {
154 return output
155 .with_result(SpfResult::PermError)
156 .with_report(&spf_record);
157 }
158 match self
159 .ip_matches(
160 macro_string.eval(&vars, &domain, true).as_ref(),
161 ip,
162 *ip4_mask,
163 *ip6_mask,
164 params.cache_ipv4,
165 params.cache_ipv6,
166 )
167 .await
168 {
169 Ok(true) => true,
170 Ok(false) | Err(Error::DnsRecordNotFound(_)) => false,
171 Err(_) => {
172 return output
173 .with_result(SpfResult::TempError)
174 .with_report(&spf_record);
175 }
176 }
177 }
178 Mechanism::Mx {
179 macro_string,
180 ip4_mask,
181 ip6_mask,
182 } => {
183 if !lookup_limit.can_lookup() {
184 return output
185 .with_result(SpfResult::PermError)
186 .with_report(&spf_record);
187 }
188
189 let mut matches = false;
190 match self
191 .mx_lookup(
192 macro_string.eval(&vars, &domain, true).as_ref(),
193 params.cache_mx,
194 )
195 .await
196 {
197 Ok(records) => {
198 for (mx_num, exchange) in records
199 .iter()
200 .flat_map(|mx| mx.exchanges.iter())
201 .enumerate()
202 {
203 if mx_num > 9 {
204 return output
205 .with_result(SpfResult::PermError)
206 .with_report(&spf_record);
207 }
208
209 match self
210 .ip_matches(
211 exchange,
212 ip,
213 *ip4_mask,
214 *ip6_mask,
215 params.cache_ipv4,
216 params.cache_ipv6,
217 )
218 .await
219 {
220 Ok(true) => {
221 matches = true;
222 break;
223 }
224 Ok(false) | Err(Error::DnsRecordNotFound(_)) => (),
225 Err(_) => {
226 return output
227 .with_result(SpfResult::TempError)
228 .with_report(&spf_record);
229 }
230 }
231 }
232 }
233 Err(Error::DnsRecordNotFound(_)) => (),
234 Err(_) => {
235 return output
236 .with_result(SpfResult::TempError)
237 .with_report(&spf_record);
238 }
239 }
240 matches
241 }
242 Mechanism::Include { macro_string } => {
243 if !lookup_limit.can_lookup() {
244 return output
245 .with_result(SpfResult::PermError)
246 .with_report(&spf_record);
247 }
248
249 let target_name = macro_string.eval(&vars, &domain, true);
250 match self
251 .txt_lookup::<Spf>(target_name.as_ref(), params.cache_txt)
252 .await
253 {
254 Ok(included_spf) => {
255 let new_domain = target_name.to_string();
256 include_stack.push((
257 std::mem::replace(&mut spf_record, included_spf),
258 pos,
259 domain,
260 ));
261 directives = spf_record.directives.iter().enumerate().skip(0);
262 domain = new_domain;
263 vars.set_domain(domain.as_bytes().to_vec());
264 continue;
265 }
266 Err(
267 Error::DnsRecordNotFound(_)
268 | Error::InvalidRecordType
269 | Error::ParseError,
270 ) => {
271 return output
272 .with_result(SpfResult::PermError)
273 .with_report(&spf_record);
274 }
275 Err(_) => {
276 return output
277 .with_result(SpfResult::TempError)
278 .with_report(&spf_record);
279 }
280 }
281 }
282 Mechanism::Ptr { macro_string } => {
283 if !lookup_limit.can_lookup() {
284 return output
285 .with_result(SpfResult::PermError)
286 .with_report(&spf_record);
287 }
288
289 let target_addr = macro_string.eval(&vars, &domain, true).to_lowercase();
290 let target_sub_addr = format!(".{target_addr}");
291 let mut matches = false;
292
293 if let Ok(records) = self.ptr_lookup(ip, params.cache_ptr).await {
294 for record in records.iter() {
295 if lookup_limit.can_lookup()
296 && let Ok(true) = self
297 .ip_matches(
298 record,
299 ip,
300 u32::MAX,
301 u128::MAX,
302 params.cache_ipv4,
303 params.cache_ipv6,
304 )
305 .await
306 {
307 matches = record == &target_addr
308 || record
309 .strip_suffix('.')
310 .unwrap_or(record.as_str())
311 .ends_with(&target_sub_addr);
312 if matches {
313 break;
314 }
315 }
316 }
317 }
318 matches
319 }
320 Mechanism::Exists { macro_string } => {
321 if !lookup_limit.can_lookup() {
322 return output
323 .with_result(SpfResult::PermError)
324 .with_report(&spf_record);
325 }
326
327 if let Ok(result) = self
328 .exists(
329 macro_string.eval(&vars, &domain, true).as_ref(),
330 params.cache_ipv4,
331 params.cache_ipv6,
332 )
333 .await
334 {
335 result
336 } else {
337 return output
338 .with_result(SpfResult::TempError)
339 .with_report(&spf_record);
340 }
341 }
342 };
343
344 if matches {
345 result = Some((&directive.qualifier).into());
346 break;
347 }
348 }
349
350 if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
352 if !lookup_limit.can_lookup() {
353 return output
354 .with_result(SpfResult::PermError)
355 .with_report(&spf_record);
356 }
357
358 let target_name = macro_string.eval(&vars, &domain, true);
359 match self
360 .txt_lookup::<Spf>(target_name.as_ref(), params.cache_txt)
361 .await
362 {
363 Ok(redirect_spf) => {
364 let new_domain = target_name.to_string();
365 spf_record = redirect_spf;
366 directives = spf_record.directives.iter().enumerate().skip(0);
367 domain = new_domain;
368 vars.set_domain(domain.as_bytes().to_vec());
369 continue;
370 }
371 Err(
372 Error::DnsRecordNotFound(_) | Error::InvalidRecordType | Error::ParseError,
373 ) => {
374 return output
375 .with_result(SpfResult::PermError)
376 .with_report(&spf_record);
377 }
378 Err(_) => {
379 return output
380 .with_result(SpfResult::TempError)
381 .with_report(&spf_record);
382 }
383 }
384 }
385
386 if let Some((prev_record, prev_pos, prev_domain)) = include_stack.pop() {
387 spf_record = prev_record;
388 directives = spf_record.directives.iter().enumerate().skip(prev_pos);
389 let (_, directive) = directives.next().unwrap();
390
391 if matches!(result, Some(SpfResult::Pass)) {
392 result = Some((&directive.qualifier).into());
393 break;
394 } else {
395 vars.set_domain(prev_domain.as_bytes().to_vec());
396 domain = prev_domain;
397 result = None;
398 }
399 } else {
400 break;
401 }
402 }
403
404 if let (Some(macro_string), Some(SpfResult::Fail)) = (&spf_record.exp, &result)
406 && let Ok(macro_string) = self
407 .txt_lookup::<Macro>(
408 macro_string.eval(&vars, &domain, true).to_string(),
409 params.cache_txt,
410 )
411 .await
412 {
413 return output
414 .with_result(SpfResult::Fail)
415 .with_explanation(macro_string.eval(&vars, &domain, false).to_string())
416 .with_report(&spf_record);
417 }
418
419 output
420 .with_result(result.unwrap_or(SpfResult::Neutral))
421 .with_report(&spf_record)
422 }
423
424 async fn ip_matches(
425 &self,
426 target_name: &str,
427 ip: IpAddr,
428 ip4_mask: u32,
429 ip6_mask: u128,
430 cache_ipv4: Option<&impl ResolverCache<String, Arc<Vec<Ipv4Addr>>>>,
431 cache_ipv6: Option<&impl ResolverCache<String, Arc<Vec<Ipv6Addr>>>>,
432 ) -> crate::Result<bool> {
433 Ok(match ip {
434 IpAddr::V4(ip) => self
435 .ipv4_lookup(target_name, cache_ipv4)
436 .await?
437 .iter()
438 .any(|addr| ip.matches_ipv4_mask(addr, ip4_mask)),
439 IpAddr::V6(ip) => self
440 .ipv6_lookup(target_name, cache_ipv6)
441 .await?
442 .iter()
443 .any(|addr| ip.matches_ipv6_mask(addr, ip6_mask)),
444 })
445 }
446}
447
448impl<'x> SpfParameters<'x> {
449 pub fn verify_ehlo(
451 ip: IpAddr,
452 helo_domain: &'x str,
453 host_domain: &'x str,
454 ) -> SpfParameters<'x> {
455 SpfParameters {
456 ip,
457 domain: helo_domain,
458 helo_domain,
459 host_domain,
460 sender: Sender::Ehlo(format!("postmaster@{helo_domain}")),
461 }
462 }
463
464 pub fn verify_mail_from(
466 ip: IpAddr,
467 helo_domain: &'x str,
468 host_domain: &'x str,
469 sender: &'x str,
470 ) -> SpfParameters<'x> {
471 SpfParameters {
472 ip,
473 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
474 helo_domain,
475 host_domain,
476 sender: Sender::MailFrom(sender),
477 }
478 }
479
480 pub fn verify(
482 ip: IpAddr,
483 helo_domain: &'x str,
484 host_domain: &'x str,
485 sender: &'x str,
486 ) -> SpfParameters<'x> {
487 SpfParameters {
488 ip,
489 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
490 helo_domain,
491 host_domain,
492 sender: Sender::Full(sender),
493 }
494 }
495
496 pub fn new(
497 ip: IpAddr,
498 domain: &'x str,
499 helo_domain: &'x str,
500 host_domain: &'x str,
501 sender: &'x str,
502 ) -> Self {
503 SpfParameters {
504 ip,
505 domain,
506 helo_domain,
507 host_domain,
508 sender: Sender::Full(sender),
509 }
510 }
511}
512
513impl<'x> From<SpfParameters<'x>>
514 for Parameters<
515 'x,
516 SpfParameters<'x>,
517 NoCache<String, Txt>,
518 NoCache<String, Arc<Vec<MX>>>,
519 NoCache<String, Arc<Vec<Ipv4Addr>>>,
520 NoCache<String, Arc<Vec<Ipv6Addr>>>,
521 NoCache<IpAddr, Arc<Vec<String>>>,
522 >
523{
524 fn from(params: SpfParameters<'x>) -> Self {
525 Parameters::new(params)
526 }
527}
528
529trait IpMask {
530 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool;
531 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool;
532}
533
534impl IpMask for IpAddr {
535 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
536 u32::from_be_bytes(match &self {
537 IpAddr::V4(ip) => ip.octets(),
538 IpAddr::V6(ip) => {
539 if let Some(ip) = ip.to_ipv4_mapped() {
540 ip.octets()
541 } else {
542 return false;
543 }
544 }
545 }) & mask
546 == u32::from_be_bytes(addr.octets()) & mask
547 }
548
549 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
550 u128::from_be_bytes(match &self {
551 IpAddr::V6(ip) => ip.octets(),
552 IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
553 }) & mask
554 == u128::from_be_bytes(addr.octets()) & mask
555 }
556}
557
558impl IpMask for Ipv6Addr {
559 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
560 u128::from_be_bytes(self.octets()) & mask == u128::from_be_bytes(addr.octets()) & mask
561 }
562
563 fn matches_ipv4_mask(&self, _addr: &Ipv4Addr, _mask: u32) -> bool {
564 unimplemented!()
565 }
566}
567
568impl IpMask for Ipv4Addr {
569 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
570 u32::from_be_bytes(self.octets()) & mask == u32::from_be_bytes(addr.octets()) & mask
571 }
572
573 fn matches_ipv6_mask(&self, _addr: &Ipv6Addr, _mask: u128) -> bool {
574 unimplemented!()
575 }
576}
577
578impl From<&Qualifier> for SpfResult {
579 fn from(q: &Qualifier) -> Self {
580 match q {
581 Qualifier::Pass => SpfResult::Pass,
582 Qualifier::Fail => SpfResult::Fail,
583 Qualifier::SoftFail => SpfResult::SoftFail,
584 Qualifier::Neutral => SpfResult::Neutral,
585 }
586 }
587}
588
589impl From<Error> for SpfResult {
590 fn from(err: Error) -> Self {
591 match err {
592 Error::DnsRecordNotFound(_) | Error::InvalidRecordType => SpfResult::None,
593 Error::ParseError => SpfResult::PermError,
594 _ => SpfResult::TempError,
595 }
596 }
597}
598
599struct LookupLimit {
600 num_lookups: u32,
601 timer: Instant,
602}
603
604impl LookupLimit {
605 pub fn new() -> Self {
606 LookupLimit {
607 num_lookups: 1,
608 timer: Instant::now(),
609 }
610 }
611
612 #[inline(always)]
613 fn can_lookup(&mut self) -> bool {
614 if self.num_lookups <= 10 && self.timer.elapsed().as_secs() < 20 {
615 self.num_lookups += 1;
616 true
617 } else {
618 false
619 }
620 }
621}
622
623pub trait HasValidLabels {
624 fn has_valid_labels(&self) -> bool;
625}
626
627impl HasValidLabels for &str {
628 fn has_valid_labels(&self) -> bool {
629 let mut has_dots = false;
630 let mut has_chars = false;
631 let mut label_len = 0;
632 for ch in self.chars() {
633 label_len += 1;
634
635 if ch.is_alphanumeric() {
636 has_chars = true;
637 } else if ch == '.' {
638 has_dots = true;
639 label_len = 0;
640 }
641
642 if label_len > 63 {
643 return false;
644 }
645 }
646 if has_chars && has_dots {
647 return true;
648 }
649 false
650 }
651}
652
653#[cfg(test)]
654#[allow(unused)]
655mod test {
656
657 use std::{
658 fs,
659 net::{IpAddr, Ipv4Addr, Ipv6Addr},
660 path::PathBuf,
661 time::{Duration, Instant},
662 };
663
664 use crate::{
665 MX, MessageAuthenticator, SpfResult,
666 common::{cache::test::DummyCaches, parse::TxtRecordParser},
667 spf::{Macro, Spf},
668 };
669
670 use super::SpfParameters;
671
672 #[tokio::test]
673 async fn spf_verify() {
674 let resolver = MessageAuthenticator::new_system_conf().unwrap();
675 let valid_until = Instant::now() + Duration::from_secs(30);
676 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
677 test_dir.push("resources");
678 test_dir.push("spf");
679
680 for file_name in fs::read_dir(&test_dir).unwrap() {
681 let file_name = file_name.unwrap().path();
682 println!("===== {} =====", file_name.display());
683 let test_suite = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
684 let caches = DummyCaches::new();
685
686 for test in test_suite.split("---\n") {
687 let mut test_name = "";
688 let mut last_test_name = "";
689 let mut helo = "";
690 let mut mail_from = "";
691 let mut client_ip = "127.0.0.1".parse::<IpAddr>().unwrap();
692 let mut test_num = 1;
693
694 for line in test.split('\n') {
695 let line = line.trim();
696 let line = if let Some(line) = line.strip_prefix('-') {
697 line.trim()
698 } else {
699 line
700 };
701
702 if let Some(name) = line.strip_prefix("name:") {
703 test_name = name.trim();
704 } else if let Some(record) = line.strip_prefix("spf:") {
705 let (name, record) = record.trim().split_once(' ').unwrap();
706 caches.txt_add(
707 name.trim().to_string(),
708 Spf::parse(record.as_bytes()),
709 valid_until,
710 );
711 } else if let Some(record) = line.strip_prefix("exp:") {
712 let (name, record) = record.trim().split_once(' ').unwrap();
713 caches.txt_add(
714 name.trim().to_string(),
715 Macro::parse(record.as_bytes()),
716 valid_until,
717 );
718 } else if let Some(record) = line.strip_prefix("a:") {
719 let (name, record) = record.trim().split_once(' ').unwrap();
720 caches.ipv4_add(
721 name.trim().to_string(),
722 record
723 .split(',')
724 .map(|item| item.trim().parse::<Ipv4Addr>().unwrap())
725 .collect(),
726 valid_until,
727 );
728 } else if let Some(record) = line.strip_prefix("aaaa:") {
729 let (name, record) = record.trim().split_once(' ').unwrap();
730 caches.ipv6_add(
731 name.trim().to_string(),
732 record
733 .split(',')
734 .map(|item| item.trim().parse::<Ipv6Addr>().unwrap())
735 .collect(),
736 valid_until,
737 );
738 } else if let Some(record) = line.strip_prefix("ptr:") {
739 let (name, record) = record.trim().split_once(' ').unwrap();
740 caches.ptr_add(
741 name.trim().parse::<IpAddr>().unwrap(),
742 record
743 .split(',')
744 .map(|item| item.trim().to_string())
745 .collect(),
746 valid_until,
747 );
748 } else if let Some(record) = line.strip_prefix("mx:") {
749 let (name, record) = record.trim().split_once(' ').unwrap();
750 let mut mxs = Vec::new();
751 for (pos, item) in record.split(',').enumerate() {
752 let ip = item.trim().parse::<IpAddr>().unwrap();
753 let mx_name = format!("mx.{ip}.{pos}");
754 match ip {
755 IpAddr::V4(ip) => {
756 caches.ipv4_add(mx_name.clone(), vec![ip], valid_until)
757 }
758 IpAddr::V6(ip) => {
759 caches.ipv6_add(mx_name.clone(), vec![ip], valid_until)
760 }
761 }
762 mxs.push(MX {
763 exchanges: vec![mx_name],
764 preference: (pos + 1) as u16,
765 });
766 }
767 caches.mx_add(name.trim().to_string(), mxs, valid_until);
768 } else if let Some(value) = line.strip_prefix("domain:") {
769 helo = value.trim();
770 } else if let Some(value) = line.strip_prefix("sender:") {
771 mail_from = value.trim();
772 } else if let Some(value) = line.strip_prefix("ip:") {
773 client_ip = value.trim().parse().unwrap();
774 } else if let Some(value) = line.strip_prefix("expect:") {
775 let value = value.trim();
776 let (result, exp): (SpfResult, &str) =
777 if let Some((result, exp)) = value.split_once(' ') {
778 (result.trim().try_into().unwrap(), exp.trim())
779 } else {
780 (value.try_into().unwrap(), "")
781 };
782 let output = resolver
783 .verify_spf(caches.parameters(SpfParameters::verify(
784 client_ip,
785 helo,
786 "localdomain.org",
787 mail_from,
788 )))
789 .await;
790 assert_eq!(
791 output.result(),
792 result,
793 "Failed for {test_name:?}, test {test_num}, ehlo: {helo}, mail-from: {mail_from}.",
794 );
795
796 if !exp.is_empty() {
797 assert_eq!(Some(exp.to_string()).as_deref(), output.explanation());
798 }
799 test_num += 1;
800 if test_name != last_test_name {
801 println!("Passed test {test_name:?}");
802 last_test_name = test_name;
803 }
804 }
805 }
806 }
807 }
808 }
809}