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