Skip to main content

sieve/compiler/
mod.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
5 */
6
7use self::{
8    grammar::{AddressPart, Capability},
9    lexer::{StringConstant, tokenizer::TokenInfo},
10};
11use crate::{
12    Compiler, Envelope, FunctionMap,
13    runtime::{RuntimeError, tests::glob::CompiledGlob},
14};
15use ahash::AHashMap;
16use arc_swap::ArcSwap;
17use mail_parser::HeaderName;
18use std::{borrow::Cow, fmt::Display, sync::Arc};
19
20pub mod grammar;
21pub mod lexer;
22
23#[derive(Debug)]
24pub struct CompileError {
25    line_num: usize,
26    line_pos: usize,
27    error_type: ErrorType,
28}
29
30#[derive(Debug)]
31pub enum ErrorType {
32    InvalidCharacter(u8),
33    InvalidNumber(String),
34    InvalidMatchVariable(usize),
35    InvalidUnicodeSequence(u32),
36    InvalidNamespace(String),
37    InvalidRegex(String),
38    InvalidExpression(String),
39    InvalidUtf8String,
40    InvalidHeaderName,
41    InvalidArguments,
42    InvalidAddress,
43    InvalidURI,
44    InvalidEnvelope(String),
45    UnterminatedString,
46    UnterminatedComment,
47    UnterminatedMultiline,
48    UnterminatedBlock,
49    ScriptTooLong,
50    StringTooLong,
51    VariableTooLong,
52    VariableIsLocal(String),
53    HeaderTooLong,
54    ExpectedConstantString,
55    UnexpectedToken {
56        expected: Cow<'static, str>,
57        found: String,
58    },
59    UnexpectedEOF,
60    TooManyNestedBlocks,
61    TooManyNestedTests,
62    TooManyNestedForEveryParts,
63    TooManyIncludes,
64    LabelAlreadyDefined(String),
65    LabelUndefined(String),
66    BreakOutsideLoop,
67    ContinueOutsideLoop,
68    UnsupportedComparator(String),
69    DuplicatedParameter,
70    UndeclaredCapability(Capability),
71    MissingTag(Cow<'static, str>),
72}
73
74impl Default for Compiler {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[cfg_attr(
82    any(test, feature = "serde"),
83    derive(serde::Serialize, serde::Deserialize)
84)]
85#[cfg_attr(
86    feature = "rkyv",
87    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
88)]
89#[cfg_attr(
90    feature = "rkyv",
91    rkyv(serialize_bounds(
92        __S: rkyv::ser::Writer + rkyv::ser::Allocator,
93        __S::Error: rkyv::rancor::Source,
94    ))
95)]
96#[cfg_attr(
97    feature = "rkyv",
98    rkyv(deserialize_bounds(__D::Error: rkyv::rancor::Source))
99)]
100#[cfg_attr(
101    feature = "rkyv",
102    rkyv(bytecheck(
103        bounds(
104            __C: rkyv::validation::ArchiveContext,
105        )
106    ))
107)]
108#[repr(u8)]
109pub(crate) enum Value {
110    Text(ConstantId) = 0,
111    Number(Number) = 1,
112    Variable(VariableType) = 2,
113    Regex(Regex) = 3,
114    Glob(Glob) = 4,
115    Header(HeaderName<'static>) = 5,
116    List(#[cfg_attr(feature = "rkyv", rkyv(omit_bounds))] Box<[Value]>) = 6,
117}
118
119#[derive(Debug)]
120pub(crate) enum RawValue {
121    Text(String),
122    Number(Number),
123    Value(Value),
124}
125
126impl From<StringConstant> for RawValue {
127    fn from(value: StringConstant) -> Self {
128        match value {
129            StringConstant::String(text) => RawValue::Text(text),
130            StringConstant::Number(number) => RawValue::Number(number),
131        }
132    }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136#[cfg_attr(
137    any(test, feature = "serde"),
138    derive(serde::Serialize, serde::Deserialize)
139)]
140#[cfg_attr(
141    feature = "rkyv",
142    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
143)]
144#[repr(transparent)]
145pub struct ConstantId(u32);
146
147impl ConstantId {
148    pub(crate) const UNRESOLVED: ConstantId = ConstantId(u32::MAX);
149
150    #[inline(always)]
151    pub(crate) fn new(index: usize) -> Self {
152        ConstantId(index as u32)
153    }
154
155    #[inline(always)]
156    pub(crate) fn index(&self) -> usize {
157        self.0 as usize
158    }
159}
160
161#[derive(Debug, Clone)]
162#[cfg_attr(
163    feature = "rkyv",
164    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
165)]
166#[cfg_attr(
167    any(test, feature = "serde"),
168    derive(serde::Serialize, serde::Deserialize)
169)]
170pub struct Regex {
171    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
172    #[cfg_attr(any(test, feature = "serde"), serde(skip, default))]
173    pub regex: LazyRegex,
174    pub expr: String,
175}
176
177#[derive(Debug, Clone)]
178pub struct LazyRegex(pub Arc<ArcSwap<Option<fancy_regex::Regex>>>);
179
180#[derive(Debug, Clone)]
181#[cfg_attr(
182    feature = "rkyv",
183    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
184)]
185#[cfg_attr(
186    any(test, feature = "serde"),
187    derive(serde::Serialize, serde::Deserialize)
188)]
189pub struct Glob {
190    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
191    #[cfg_attr(any(test, feature = "serde"), serde(skip, default))]
192    pub glob: LazyGlob,
193    pub expr: String,
194}
195
196#[derive(Debug, Clone)]
197pub struct LazyGlob(pub(crate) Arc<ArcSwap<Option<CompiledGlob>>>);
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200#[cfg_attr(
201    any(test, feature = "serde"),
202    derive(serde::Serialize, serde::Deserialize)
203)]
204#[cfg_attr(
205    feature = "rkyv",
206    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
207)]
208#[repr(u8)]
209pub enum VariableType {
210    Local(u16) = 0,
211    Match(u8) = 1,
212    Global(String) = 2,
213    Environment(String) = 3,
214    Envelope(Envelope) = 4,
215    Header(HeaderVariable<'static>) = 5,
216    Part(MessagePart) = 6,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220#[cfg_attr(
221    any(test, feature = "serde"),
222    derive(serde::Serialize, serde::Deserialize)
223)]
224#[cfg_attr(
225    feature = "rkyv",
226    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
227)]
228pub struct Transform {
229    pub variable: Box<VariableType>,
230    pub functions: Box<[u32]>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234#[cfg_attr(
235    any(test, feature = "serde"),
236    derive(serde::Serialize, serde::Deserialize)
237)]
238#[cfg_attr(
239    feature = "rkyv",
240    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
241)]
242pub struct HeaderVariable<'x> {
243    pub name: Box<[HeaderName<'x>]>,
244    pub part: HeaderPart,
245    pub index_hdr: i32,
246    pub index_part: i32,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
250#[cfg_attr(
251    any(test, feature = "serde"),
252    derive(serde::Serialize, serde::Deserialize)
253)]
254#[cfg_attr(
255    feature = "rkyv",
256    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
257)]
258#[repr(u8)]
259pub enum MessagePart {
260    TextBody(bool) = 0,
261    HtmlBody(bool) = 1,
262    Contents = 2,
263    Raw = 3,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267#[cfg_attr(
268    any(test, feature = "serde"),
269    derive(serde::Serialize, serde::Deserialize)
270)]
271#[cfg_attr(
272    feature = "rkyv",
273    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
274)]
275#[repr(u8)]
276pub enum HeaderPart {
277    Text = 0,
278    Date = 1,
279    Id = 2,
280    Address(AddressPart) = 3,
281    ContentType(ContentTypePart) = 4,
282    Received(ReceivedPart) = 5,
283    Raw = 6,
284    RawName = 7,
285    Exists = 8,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289#[cfg_attr(
290    any(test, feature = "serde"),
291    derive(serde::Serialize, serde::Deserialize)
292)]
293#[cfg_attr(
294    feature = "rkyv",
295    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
296)]
297#[repr(u8)]
298pub enum ContentTypePart {
299    Type = 0,
300    Subtype = 1,
301    Attribute(String) = 2,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305#[cfg_attr(
306    any(test, feature = "serde"),
307    derive(serde::Serialize, serde::Deserialize)
308)]
309#[cfg_attr(
310    feature = "rkyv",
311    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
312)]
313#[repr(u8)]
314pub enum ReceivedPart {
315    From(ReceivedHostname) = 0,
316    FromIp = 1,
317    FromIpRev = 2,
318    By(ReceivedHostname) = 3,
319    For = 4,
320    With = 5,
321    TlsVersion = 6,
322    TlsCipher = 7,
323    Id = 8,
324    Ident = 9,
325    Via = 10,
326    Date = 11,
327    DateRaw = 12,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
331#[cfg_attr(
332    any(test, feature = "serde"),
333    derive(serde::Serialize, serde::Deserialize)
334)]
335#[cfg_attr(
336    feature = "rkyv",
337    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
338)]
339#[repr(u8)]
340pub enum ReceivedHostname {
341    Name = 0,
342    Ip = 1,
343    Any = 2,
344}
345
346#[derive(Debug, Clone, Copy)]
347#[cfg_attr(
348    any(test, feature = "serde"),
349    derive(serde::Serialize, serde::Deserialize)
350)]
351#[cfg_attr(
352    feature = "rkyv",
353    derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive)
354)]
355#[repr(u8)]
356pub enum Number {
357    Integer(i64) = 0,
358    Float(f64) = 1,
359}
360
361impl Number {
362    #[cfg(test)]
363    pub fn to_float(&self) -> f64 {
364        match self {
365            Number::Integer(i) => *i as f64,
366            Number::Float(fl) => *fl,
367        }
368    }
369}
370
371impl From<Number> for usize {
372    fn from(value: Number) -> Self {
373        match value {
374            Number::Integer(i) => i as usize,
375            Number::Float(fl) => fl as usize,
376        }
377    }
378}
379
380impl Display for Number {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        match self {
383            Number::Integer(i) => i.fmt(f),
384            Number::Float(fl) => fl.fmt(f),
385        }
386    }
387}
388
389impl Compiler {
390    pub const VERSION: u32 = 3;
391
392    pub fn new() -> Self {
393        Compiler {
394            max_script_size: 1024 * 1024,
395            max_string_size: 4096,
396            max_variable_name_size: 32,
397            max_nested_blocks: 15,
398            max_nested_tests: 15,
399            max_nested_foreverypart: 3,
400            max_match_variables: 30,
401            max_local_variables: 128,
402            max_header_size: 1024,
403            max_includes: 6,
404            functions: AHashMap::new(),
405            no_capability_check: false,
406        }
407    }
408
409    pub fn set_max_header_size(&mut self, size: usize) {
410        self.max_header_size = size;
411    }
412
413    pub fn with_max_header_size(mut self, size: usize) -> Self {
414        self.max_header_size = size;
415        self
416    }
417
418    pub fn set_max_includes(&mut self, size: usize) {
419        self.max_includes = size;
420    }
421
422    pub fn with_max_includes(mut self, size: usize) -> Self {
423        self.max_includes = size;
424        self
425    }
426
427    pub fn set_max_nested_blocks(&mut self, size: usize) {
428        self.max_nested_blocks = size;
429    }
430
431    pub fn with_max_nested_blocks(mut self, size: usize) -> Self {
432        self.max_nested_blocks = size;
433        self
434    }
435
436    pub fn set_max_nested_tests(&mut self, size: usize) {
437        self.max_nested_tests = size;
438    }
439
440    pub fn with_max_nested_tests(mut self, size: usize) -> Self {
441        self.max_nested_tests = size;
442        self
443    }
444
445    pub fn set_max_nested_foreverypart(&mut self, size: usize) {
446        self.max_nested_foreverypart = size;
447    }
448
449    pub fn with_max_nested_foreverypart(mut self, size: usize) -> Self {
450        self.max_nested_foreverypart = size;
451        self
452    }
453
454    pub fn set_max_script_size(&mut self, size: usize) {
455        self.max_script_size = size;
456    }
457
458    pub fn with_max_script_size(mut self, size: usize) -> Self {
459        self.max_script_size = size;
460        self
461    }
462
463    pub fn set_max_string_size(&mut self, size: usize) {
464        self.max_string_size = size;
465    }
466
467    pub fn with_max_string_size(mut self, size: usize) -> Self {
468        self.max_string_size = size;
469        self
470    }
471
472    pub fn set_max_variable_name_size(&mut self, size: usize) {
473        self.max_variable_name_size = size;
474    }
475
476    pub fn with_max_variable_name_size(mut self, size: usize) -> Self {
477        self.max_variable_name_size = size;
478        self
479    }
480
481    pub fn set_max_match_variables(&mut self, size: usize) {
482        self.max_match_variables = size;
483    }
484
485    pub fn with_max_match_variables(mut self, size: usize) -> Self {
486        self.max_match_variables = size;
487        self
488    }
489
490    pub fn set_max_local_variables(&mut self, size: usize) {
491        self.max_local_variables = size;
492    }
493
494    pub fn with_max_local_variables(mut self, size: usize) -> Self {
495        self.max_local_variables = size;
496        self
497    }
498
499    pub fn register_functions(mut self, fnc_map: &mut FunctionMap) -> Self {
500        self.functions = std::mem::take(&mut fnc_map.map);
501        self
502    }
503
504    pub fn with_no_capability_check(mut self, value: bool) -> Self {
505        self.no_capability_check = value;
506        self
507    }
508
509    pub fn set_no_capability_check(&mut self, value: bool) {
510        self.no_capability_check = value;
511    }
512}
513
514impl CompileError {
515    pub fn line_num(&self) -> usize {
516        self.line_num
517    }
518
519    pub fn line_pos(&self) -> usize {
520        self.line_pos
521    }
522
523    pub fn error_type(&self) -> &ErrorType {
524        &self.error_type
525    }
526}
527
528impl PartialEq for Regex {
529    fn eq(&self, other: &Self) -> bool {
530        self.expr == other.expr
531    }
532}
533
534impl Eq for Regex {}
535
536impl TokenInfo {
537    pub fn expected(self, expected: impl Into<Cow<'static, str>>) -> CompileError {
538        CompileError {
539            line_num: self.line_num,
540            line_pos: self.line_pos,
541            error_type: ErrorType::UnexpectedToken {
542                expected: expected.into(),
543                found: self.token.to_string(),
544            },
545        }
546    }
547
548    pub fn missing_tag(self, tag: impl Into<Cow<'static, str>>) -> CompileError {
549        CompileError {
550            line_num: self.line_num,
551            line_pos: self.line_pos,
552            error_type: ErrorType::MissingTag(tag.into()),
553        }
554    }
555
556    pub fn custom(self, error_type: ErrorType) -> CompileError {
557        CompileError {
558            line_num: self.line_num,
559            line_pos: self.line_pos,
560            error_type,
561        }
562    }
563}
564
565impl Default for LazyRegex {
566    fn default() -> Self {
567        Self(Arc::new(ArcSwap::new(Arc::new(None))))
568    }
569}
570
571impl Default for LazyGlob {
572    fn default() -> Self {
573        Self(Arc::new(ArcSwap::new(Arc::new(None))))
574    }
575}
576
577impl Glob {
578    pub fn new(expr: String, to_lower: bool) -> Self {
579        let compiled = CompiledGlob::compile(&expr, to_lower);
580        Self {
581            expr,
582            glob: LazyGlob(Arc::new(ArcSwap::new(Arc::new(Some(compiled))))),
583        }
584    }
585}
586
587impl PartialEq for Glob {
588    fn eq(&self, other: &Self) -> bool {
589        self.expr == other.expr
590    }
591}
592
593impl Eq for Glob {}
594
595impl Regex {
596    pub fn new(expr: String, regex: fancy_regex::Regex) -> Self {
597        Self {
598            expr,
599            regex: LazyRegex(Arc::new(ArcSwap::new(Arc::new(Some(regex))))),
600        }
601    }
602}
603
604impl Display for CompileError {
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        match &self.error_type() {
607            ErrorType::InvalidCharacter(value) => {
608                write!(f, "Invalid character {:?}", char::from(*value))
609            }
610            ErrorType::InvalidNumber(value) => write!(f, "Invalid number {value:?}"),
611            ErrorType::InvalidMatchVariable(value) => {
612                write!(f, "Match variable {value} out of range")
613            }
614            ErrorType::InvalidUnicodeSequence(value) => {
615                write!(f, "Invalid Unicode sequence {value:04x}")
616            }
617            ErrorType::InvalidNamespace(value) => write!(f, "Invalid namespace {value:?}"),
618            ErrorType::InvalidRegex(value) => write!(f, "Invalid regular expression {value:?}"),
619            ErrorType::InvalidExpression(value) => write!(f, "Invalid expression {value}"),
620            ErrorType::InvalidUtf8String => write!(f, "Invalid UTF-8 string"),
621            ErrorType::InvalidHeaderName => write!(f, "Invalid header name"),
622            ErrorType::InvalidArguments => write!(f, "Invalid Arguments"),
623            ErrorType::InvalidAddress => write!(f, "Invalid Address"),
624            ErrorType::InvalidURI => write!(f, "Invalid URI"),
625            ErrorType::InvalidEnvelope(value) => write!(f, "Invalid envelope {value:?}"),
626            ErrorType::UnterminatedString => write!(f, "Unterminated string"),
627            ErrorType::UnterminatedComment => write!(f, "Unterminated comment"),
628            ErrorType::UnterminatedMultiline => write!(f, "Unterminated multi-line string"),
629            ErrorType::UnterminatedBlock => write!(f, "Unterminated block"),
630            ErrorType::ScriptTooLong => write!(f, "Sieve script is too large"),
631            ErrorType::StringTooLong => write!(f, "String is too long"),
632            ErrorType::VariableTooLong => write!(f, "Variable name is too long"),
633            ErrorType::VariableIsLocal(value) => {
634                write!(f, "Variable {value:?} was already defined as local")
635            }
636            ErrorType::HeaderTooLong => write!(f, "Header value is too long"),
637            ErrorType::ExpectedConstantString => write!(f, "Expected a constant string"),
638            ErrorType::UnexpectedToken { expected, found } => {
639                write!(f, "Expected token {expected:?} but found {found:?}")
640            }
641            ErrorType::UnexpectedEOF => write!(f, "Unexpected end of file"),
642            ErrorType::TooManyNestedBlocks => write!(f, "Too many nested blocks"),
643            ErrorType::TooManyNestedTests => write!(f, "Too many nested tests"),
644            ErrorType::TooManyNestedForEveryParts => {
645                write!(f, "Too many nested foreverypart blocks")
646            }
647            ErrorType::TooManyIncludes => write!(f, "Too many includes"),
648            ErrorType::LabelAlreadyDefined(value) => write!(f, "Label {value:?} already defined"),
649            ErrorType::LabelUndefined(value) => write!(f, "Label {value:?} does not exist"),
650            ErrorType::BreakOutsideLoop => write!(f, "Break used outside of foreverypart loop"),
651            ErrorType::ContinueOutsideLoop => write!(f, "Continue used outside of while loop"),
652            ErrorType::UnsupportedComparator(value) => {
653                write!(f, "Comparator {value:?} is not supported")
654            }
655            ErrorType::DuplicatedParameter => write!(f, "Duplicated argument"),
656            ErrorType::UndeclaredCapability(value) => {
657                write!(f, "Undeclared capability '{value}'")
658            }
659            ErrorType::MissingTag(value) => write!(f, "Missing tag {value:?}"),
660        }?;
661
662        write!(
663            f,
664            " at line {}, column {}.",
665            self.line_num(),
666            self.line_pos()
667        )
668    }
669}
670
671impl Display for RuntimeError {
672    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        match self {
674            RuntimeError::TooManyIncludes => write!(f, ""),
675            RuntimeError::InvalidInstruction(value) => write!(
676                f,
677                "Script executed invalid instruction {:?} at line {}, column {}.",
678                value.name(),
679                value.line_pos(),
680                value.line_num()
681            ),
682            RuntimeError::ScriptErrorMessage(value) => {
683                write!(f, "Script reported error {value:?}.")
684            }
685            RuntimeError::CapabilityNotAllowed(value) => {
686                write!(f, "Capability '{value}' has been disabled.")
687            }
688            RuntimeError::CapabilityNotSupported(value) => {
689                write!(f, "Capability '{value}' not supported.")
690            }
691            RuntimeError::CPULimitReached => write!(
692                f,
693                "Script exceeded the maximum number of instructions allowed to execute."
694            ),
695        }
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use std::{fs, path::PathBuf};
702
703    use crate::Compiler;
704
705    #[test]
706    fn parse_rfc() {
707        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
708        test_dir.push("tests");
709        test_dir.push("rfcs");
710        let mut tests_run = 0;
711
712        let compiler = Compiler::new().with_max_nested_foreverypart(10);
713
714        for file_name in fs::read_dir(&test_dir).unwrap() {
715            let mut file_name = file_name.unwrap().path();
716            if file_name.extension().is_some_and(|e| e == "sieve") {
717                println!("Parsing {}", file_name.display());
718
719                /*if !file_name
720                    .file_name()
721                    .unwrap()
722                    .to_str()
723                    .unwrap()
724                    .contains("plugins")
725                {
726                    let test = "true";
727                    continue;
728                }*/
729
730                let script = fs::read(&file_name).unwrap();
731                file_name.set_extension("json");
732                let expected_result = fs::read(&file_name).unwrap();
733
734                tests_run += 1;
735
736                let sieve = compiler.compile(&script).unwrap();
737
738                #[cfg(feature = "rkyv")]
739                assert_eq!(
740                    crate::Sieve::from_bytes(&sieve.to_bytes().unwrap()).unwrap(),
741                    sieve,
742                    "rkyv round trip altered {}",
743                    file_name.display()
744                );
745
746                let json_sieve = serde_json::to_string_pretty(
747                    &sieve
748                        .instructions
749                        .into_iter()
750                        .enumerate()
751                        .collect::<Vec<_>>(),
752                )
753                .unwrap();
754
755                if json_sieve.as_bytes() != expected_result {
756                    file_name.set_extension("failed");
757                    fs::write(&file_name, json_sieve.as_bytes()).unwrap();
758                    panic!("Test failed, parsed sieve saved to {}", file_name.display());
759                }
760            }
761        }
762
763        assert!(
764            tests_run > 0,
765            "Did not find any tests to run in folder {}.",
766            test_dir.display()
767        );
768    }
769}