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((prev_record, prev_pos, prev_domain)) = include_stack.pop() {
353 spf_record = prev_record;
354 directives = spf_record.directives.iter().enumerate().skip(prev_pos);
355 let (_, directive) = directives.next().unwrap();
356
357 if matches!(result, Some(SpfResult::Pass)) {
358 result = Some((&directive.qualifier).into());
359 break;
360 } else {
361 vars.set_domain(prev_domain.as_bytes().to_vec());
362 domain = prev_domain;
363 result = None;
364 }
365 } else {
366 if let (Some(macro_string), None) = (&spf_record.redirect, &result) {
368 if !lookup_limit.can_lookup() {
369 return output
370 .with_result(SpfResult::PermError)
371 .with_report(&spf_record);
372 }
373
374 let target_name = macro_string.eval(&vars, &domain, true);
375 match self
376 .txt_lookup::<Spf>(target_name.as_ref(), params.cache_txt)
377 .await
378 {
379 Ok(redirect_spf) => {
380 let new_domain = target_name.to_string();
381 spf_record = redirect_spf;
382 directives = spf_record.directives.iter().enumerate().skip(0);
383 domain = new_domain;
384 vars.set_domain(domain.as_bytes().to_vec());
385 continue;
386 }
387 Err(
388 Error::DnsRecordNotFound(_)
389 | Error::InvalidRecordType
390 | Error::ParseError,
391 ) => {
392 return output
393 .with_result(SpfResult::PermError)
394 .with_report(&spf_record);
395 }
396 Err(_) => {
397 return output
398 .with_result(SpfResult::TempError)
399 .with_report(&spf_record);
400 }
401 }
402 }
403
404 break;
405 }
406 }
407
408 if let (Some(macro_string), Some(SpfResult::Fail)) = (&spf_record.exp, &result)
410 && let Ok(macro_string) = self
411 .txt_lookup::<Macro>(
412 macro_string.eval(&vars, &domain, true).to_string(),
413 params.cache_txt,
414 )
415 .await
416 {
417 return output
418 .with_result(SpfResult::Fail)
419 .with_explanation(macro_string.eval(&vars, &domain, false).to_string())
420 .with_report(&spf_record);
421 }
422
423 output
424 .with_result(result.unwrap_or(SpfResult::Neutral))
425 .with_report(&spf_record)
426 }
427
428 async fn ip_matches(
429 &self,
430 target_name: &str,
431 ip: IpAddr,
432 ip4_mask: u32,
433 ip6_mask: u128,
434 cache_ipv4: Option<&impl ResolverCache<String, Arc<Vec<Ipv4Addr>>>>,
435 cache_ipv6: Option<&impl ResolverCache<String, Arc<Vec<Ipv6Addr>>>>,
436 ) -> crate::Result<bool> {
437 Ok(match ip {
438 IpAddr::V4(ip) => self
439 .ipv4_lookup(target_name, cache_ipv4)
440 .await?
441 .iter()
442 .any(|addr| ip.matches_ipv4_mask(addr, ip4_mask)),
443 IpAddr::V6(ip) => self
444 .ipv6_lookup(target_name, cache_ipv6)
445 .await?
446 .iter()
447 .any(|addr| ip.matches_ipv6_mask(addr, ip6_mask)),
448 })
449 }
450}
451
452impl<'x> SpfParameters<'x> {
453 pub fn verify_ehlo(
455 ip: IpAddr,
456 helo_domain: &'x str,
457 host_domain: &'x str,
458 ) -> SpfParameters<'x> {
459 SpfParameters {
460 ip,
461 domain: helo_domain,
462 helo_domain,
463 host_domain,
464 sender: Sender::Ehlo(format!("postmaster@{helo_domain}")),
465 }
466 }
467
468 pub fn verify_mail_from(
470 ip: IpAddr,
471 helo_domain: &'x str,
472 host_domain: &'x str,
473 sender: &'x str,
474 ) -> SpfParameters<'x> {
475 SpfParameters {
476 ip,
477 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
478 helo_domain,
479 host_domain,
480 sender: Sender::MailFrom(sender),
481 }
482 }
483
484 pub fn verify(
486 ip: IpAddr,
487 helo_domain: &'x str,
488 host_domain: &'x str,
489 sender: &'x str,
490 ) -> SpfParameters<'x> {
491 SpfParameters {
492 ip,
493 domain: sender.rsplit_once('@').map_or(helo_domain, |(_, d)| d),
494 helo_domain,
495 host_domain,
496 sender: Sender::Full(sender),
497 }
498 }
499
500 pub fn new(
501 ip: IpAddr,
502 domain: &'x str,
503 helo_domain: &'x str,
504 host_domain: &'x str,
505 sender: &'x str,
506 ) -> Self {
507 SpfParameters {
508 ip,
509 domain,
510 helo_domain,
511 host_domain,
512 sender: Sender::Full(sender),
513 }
514 }
515}
516
517impl<'x> From<SpfParameters<'x>>
518 for Parameters<
519 'x,
520 SpfParameters<'x>,
521 NoCache<String, Txt>,
522 NoCache<String, Arc<Vec<MX>>>,
523 NoCache<String, Arc<Vec<Ipv4Addr>>>,
524 NoCache<String, Arc<Vec<Ipv6Addr>>>,
525 NoCache<IpAddr, Arc<Vec<String>>>,
526 >
527{
528 fn from(params: SpfParameters<'x>) -> Self {
529 Parameters::new(params)
530 }
531}
532
533trait IpMask {
534 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool;
535 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool;
536}
537
538impl IpMask for IpAddr {
539 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
540 u32::from_be_bytes(match &self {
541 IpAddr::V4(ip) => ip.octets(),
542 IpAddr::V6(ip) => {
543 if let Some(ip) = ip.to_ipv4_mapped() {
544 ip.octets()
545 } else {
546 return false;
547 }
548 }
549 }) & mask
550 == u32::from_be_bytes(addr.octets()) & mask
551 }
552
553 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
554 u128::from_be_bytes(match &self {
555 IpAddr::V6(ip) => ip.octets(),
556 IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
557 }) & mask
558 == u128::from_be_bytes(addr.octets()) & mask
559 }
560}
561
562impl IpMask for Ipv6Addr {
563 fn matches_ipv6_mask(&self, addr: &Ipv6Addr, mask: u128) -> bool {
564 u128::from_be_bytes(self.octets()) & mask == u128::from_be_bytes(addr.octets()) & mask
565 }
566
567 fn matches_ipv4_mask(&self, _addr: &Ipv4Addr, _mask: u32) -> bool {
568 unimplemented!()
569 }
570}
571
572impl IpMask for Ipv4Addr {
573 fn matches_ipv4_mask(&self, addr: &Ipv4Addr, mask: u32) -> bool {
574 u32::from_be_bytes(self.octets()) & mask == u32::from_be_bytes(addr.octets()) & mask
575 }
576
577 fn matches_ipv6_mask(&self, _addr: &Ipv6Addr, _mask: u128) -> bool {
578 unimplemented!()
579 }
580}
581
582impl From<&Qualifier> for SpfResult {
583 fn from(q: &Qualifier) -> Self {
584 match q {
585 Qualifier::Pass => SpfResult::Pass,
586 Qualifier::Fail => SpfResult::Fail,
587 Qualifier::SoftFail => SpfResult::SoftFail,
588 Qualifier::Neutral => SpfResult::Neutral,
589 }
590 }
591}
592
593impl From<Error> for SpfResult {
594 fn from(err: Error) -> Self {
595 match err {
596 Error::DnsRecordNotFound(_) | Error::InvalidRecordType => SpfResult::None,
597 Error::ParseError => SpfResult::PermError,
598 _ => SpfResult::TempError,
599 }
600 }
601}
602
603struct LookupLimit {
604 num_lookups: u32,
605 timer: Instant,
606}
607
608impl LookupLimit {
609 pub fn new() -> Self {
610 LookupLimit {
611 num_lookups: 1,
612 timer: Instant::now(),
613 }
614 }
615
616 #[inline(always)]
617 fn can_lookup(&mut self) -> bool {
618 if self.num_lookups <= 10 && self.timer.elapsed().as_secs() < 20 {
619 self.num_lookups += 1;
620 true
621 } else {
622 false
623 }
624 }
625}
626
627pub trait HasValidLabels {
628 fn has_valid_labels(&self) -> bool;
629}
630
631impl HasValidLabels for &str {
632 fn has_valid_labels(&self) -> bool {
633 let mut has_dots = false;
634 let mut has_chars = false;
635 let mut label_len = 0;
636 for ch in self.chars() {
637 label_len += 1;
638
639 if ch.is_alphanumeric() {
640 has_chars = true;
641 } else if ch == '.' {
642 has_dots = true;
643 label_len = 0;
644 }
645
646 if label_len > 63 {
647 return false;
648 }
649 }
650 if has_chars && has_dots {
651 return true;
652 }
653 false
654 }
655}
656
657#[cfg(test)]
658#[allow(unused)]
659mod test {
660
661 use std::{
662 fs,
663 net::{IpAddr, Ipv4Addr, Ipv6Addr},
664 path::PathBuf,
665 time::{Duration, Instant},
666 };
667
668 use crate::{
669 MX, MessageAuthenticator, SpfResult,
670 common::{cache::test::DummyCaches, parse::TxtRecordParser},
671 spf::{Macro, Spf},
672 };
673
674 use super::SpfParameters;
675
676 #[tokio::test]
677 async fn spf_verify() {
678 let resolver = MessageAuthenticator::new_system_conf().unwrap();
679 let valid_until = Instant::now() + Duration::from_secs(30);
680 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
681 test_dir.push("resources");
682 test_dir.push("spf");
683
684 for file_name in fs::read_dir(&test_dir).unwrap() {
685 let file_name = file_name.unwrap().path();
686 println!("===== {} =====", file_name.display());
687 let test_suite = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
688 let caches = DummyCaches::new();
689
690 for test in test_suite.split("---\n") {
691 let mut test_name = "";
692 let mut last_test_name = "";
693 let mut helo = "";
694 let mut mail_from = "";
695 let mut client_ip = "127.0.0.1".parse::<IpAddr>().unwrap();
696 let mut test_num = 1;
697
698 for line in test.split('\n') {
699 let line = line.trim();
700 let line = if let Some(line) = line.strip_prefix('-') {
701 line.trim()
702 } else {
703 line
704 };
705
706 if let Some(name) = line.strip_prefix("name:") {
707 test_name = name.trim();
708 } else if let Some(record) = line.strip_prefix("spf:") {
709 let (name, record) = record.trim().split_once(' ').unwrap();
710 caches.txt_add(
711 name.trim().to_string(),
712 Spf::parse(record.as_bytes()),
713 valid_until,
714 );
715 } else if let Some(record) = line.strip_prefix("exp:") {
716 let (name, record) = record.trim().split_once(' ').unwrap();
717 caches.txt_add(
718 name.trim().to_string(),
719 Macro::parse(record.as_bytes()),
720 valid_until,
721 );
722 } else if let Some(record) = line.strip_prefix("a:") {
723 let (name, record) = record.trim().split_once(' ').unwrap();
724 caches.ipv4_add(
725 name.trim().to_string(),
726 record
727 .split(',')
728 .map(|item| item.trim().parse::<Ipv4Addr>().unwrap())
729 .collect(),
730 valid_until,
731 );
732 } else if let Some(record) = line.strip_prefix("aaaa:") {
733 let (name, record) = record.trim().split_once(' ').unwrap();
734 caches.ipv6_add(
735 name.trim().to_string(),
736 record
737 .split(',')
738 .map(|item| item.trim().parse::<Ipv6Addr>().unwrap())
739 .collect(),
740 valid_until,
741 );
742 } else if let Some(record) = line.strip_prefix("ptr:") {
743 let (name, record) = record.trim().split_once(' ').unwrap();
744 caches.ptr_add(
745 name.trim().parse::<IpAddr>().unwrap(),
746 record
747 .split(',')
748 .map(|item| item.trim().to_string())
749 .collect(),
750 valid_until,
751 );
752 } else if let Some(record) = line.strip_prefix("mx:") {
753 let (name, record) = record.trim().split_once(' ').unwrap();
754 let mut mxs = Vec::new();
755 for (pos, item) in record.split(',').enumerate() {
756 let ip = item.trim().parse::<IpAddr>().unwrap();
757 let mx_name = format!("mx.{ip}.{pos}");
758 match ip {
759 IpAddr::V4(ip) => {
760 caches.ipv4_add(mx_name.clone(), vec![ip], valid_until)
761 }
762 IpAddr::V6(ip) => {
763 caches.ipv6_add(mx_name.clone(), vec![ip], valid_until)
764 }
765 }
766 mxs.push(MX {
767 exchanges: vec![mx_name],
768 preference: (pos + 1) as u16,
769 });
770 }
771 caches.mx_add(name.trim().to_string(), mxs, valid_until);
772 } else if let Some(value) = line.strip_prefix("domain:") {
773 helo = value.trim();
774 } else if let Some(value) = line.strip_prefix("sender:") {
775 mail_from = value.trim();
776 } else if let Some(value) = line.strip_prefix("ip:") {
777 client_ip = value.trim().parse().unwrap();
778 } else if let Some(value) = line.strip_prefix("expect:") {
779 let value = value.trim();
780 let (result, exp): (SpfResult, &str) =
781 if let Some((result, exp)) = value.split_once(' ') {
782 (result.trim().try_into().unwrap(), exp.trim())
783 } else {
784 (value.try_into().unwrap(), "")
785 };
786 let output = resolver
787 .verify_spf(caches.parameters(SpfParameters::verify(
788 client_ip,
789 helo,
790 "localdomain.org",
791 mail_from,
792 )))
793 .await;
794 assert_eq!(
795 output.result(),
796 result,
797 "Failed for {test_name:?}, test {test_num}, ehlo: {helo}, mail-from: {mail_from}.",
798 );
799
800 if !exp.is_empty() {
801 assert_eq!(Some(exp.to_string()).as_deref(), output.explanation());
802 }
803 test_num += 1;
804 if test_name != last_test_name {
805 println!("Passed test {test_name:?}");
806 last_test_name = test_name;
807 }
808 }
809 }
810 }
811 }
812 }
813}