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