From dacc7167cd60a2d82ce7b379535664269dc1b67c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 11:41:38 -0700 Subject: [PATCH] Sieve: match any key, and :regex dates as regular expressions string compared each key against every source, but its break on a match only left the loop over sources, so the next key overwrote the result: string :is "a" ["a", "b"] was false while ["b", "a"] was true. RFC 5229 makes a test true when any key matches. It now stops at the first match. date :regex and currentdate :regex called the :matches comparator, so a regular expression was read as a wildcard pattern and "^20[0-9]{2}$" never matched a year. They now use the regex comparator, with its size limit and cache, like every other test. sieve_tests_match_any_key_and_regex_dates in crates/common covers both and fails without the fix. Upstream's svtests (restored in full) pass. --- crates/common/src/config/mailstore/scripts.rs | 48 +++++++++++++++++++ vendor/sieve-rs/VENDORED.md | 10 ++++ .../sieve-rs/src/runtime/tests/test_date.rs | 22 ++++++--- .../sieve-rs/src/runtime/tests/test_string.rs | 7 ++- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/crates/common/src/config/mailstore/scripts.rs b/crates/common/src/config/mailstore/scripts.rs index 9cdb1fc7..6f347621 100644 --- a/crates/common/src/config/mailstore/scripts.rs +++ b/crates/common/src/config/mailstore/scripts.rs @@ -395,4 +395,52 @@ if header :regex "x-h" "${p}" { discard; } ); } } + + // inbuxa: a test is true when any key matches (RFC 5229, 5260), whatever + // the keys' order, and :regex on a date part is a regular expression, + // not a wildcard pattern + #[test] + fn sieve_tests_match_any_key_and_regex_dates() { + let runtime = sieve::Runtime::new(); + let matched = |test: &str| { + let script = format!( + "require [\"fileinto\", \"variables\", \"date\", \"regex\"];\nif {test} {{ fileinto \"matched\"; }}\n" + ); + let script = std::sync::Arc::new( + sieve::Compiler::new() + .compile(script.as_bytes()) + .unwrap_or_else(|err| panic!("{test}: {err}")), + ); + let message = "Date: Tue, 27 Oct 2026 09:30:00 +0000\r\nSubject: hi\r\n\r\nbody\r\n"; + let mut ctx = runtime.filter(message.as_bytes()); + let mut input = sieve::Input::script("test", script); + let mut filed = false; + while let Some(event) = ctx.run(input) { + if let sieve::Event::FileInto { folder, .. } = event.unwrap() { + filed |= folder == "matched"; + } + input = sieve::Input::True; + } + filed + }; + for test in [ + r#"string :is "a" ["a", "b"]"#, + r#"string :is "a" ["b", "a"]"#, + r#"string :is ["x", "a"] ["a", "b"]"#, + r#"date :regex "date" "year" "^20[0-9]{2}$""#, + r#"date :regex "date" "month" ["^1[12]$", "^10$"]"#, + r#"date :regex "date" "month" "1.""#, + r#"currentdate :regex "year" "^[0-9]{4}$""#, + ] { + assert!(matched(test), "{test} should match"); + } + for test in [ + r#"string :is "a" ["b", "c"]"#, + r#"date :regex "date" "year" "^19[0-9]{2}$""#, + r#"date :regex "date" "month" "^0[1-9]$""#, + r#"currentdate :regex "year" "^[0-9]{2}$""#, + ] { + assert!(!matched(test), "{test} should not match"); + } + } } diff --git a/vendor/sieve-rs/VENDORED.md b/vendor/sieve-rs/VENDORED.md index dcd353d6..555f3cf3 100644 --- a/vendor/sieve-rs/VENDORED.md +++ b/vendor/sieve-rs/VENDORED.md @@ -50,6 +50,16 @@ Every changed file carries the AGPL 5(a) notice in its header. Upstream 0.7.3 fails its own svtests "No reply to auto-submitted mail" and "Notifying on automated messages" for this reason. The `sieve_auto_submitted_mail_gets_no_reply` test in `crates/common` covers it. +- `src/runtime/tests/test_string.rs`: `string` is true when any key matches + any source. Upstream's `break` on a match only left the loop over sources, + so the next key overwrote the result: `string :is "a" ["a", "b"]` was + false while `["b", "a"]` was true. +- `src/runtime/tests/test_date.rs`: `date :regex` and `currentdate :regex` + compare with the regex comparator (through the same size limit and + cache); upstream compared them as `:matches` wildcard patterns, so + `"^20[0-9]{2}$"` never matched a year. The + `sieve_tests_match_any_key_and_regex_dates` test in `crates/common` + covers both. - `Cargo.toml`: the example target is dropped, with the top-level `examples/` and `tests/`, which the server never builds. (`src/` keeps its own `tests` modules: the crate declares them.) diff --git a/vendor/sieve-rs/src/runtime/tests/test_date.rs b/vendor/sieve-rs/src/runtime/tests/test_date.rs index 6f118990..d736e63e 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_date.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_date.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::borrow::Cow; @@ -94,7 +96,7 @@ impl TestDate { if let Some(dt) = ctx.find_dates(header) { let date_part = self.date_part.eval(self.zone.eval(dt.as_ref()).as_ref()); - for key in &key_list { + for (key, pattern) in key_list.iter().zip(self.key_list.iter()) { if match &self.match_type { MatchType::Is => self.comparator.is(&date_part.as_str(), key), MatchType::Contains => self @@ -113,9 +115,12 @@ impl TestDate { &mut captured_values, ) } - MatchType::Regex(capture_positions) => self.comparator.matches( + // inbuxa: :regex compared as a wildcard pattern + MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, + pattern, + key, &date_part, - key.to_string().as_ref(), *capture_positions, &mut captured_values, ), @@ -182,8 +187,8 @@ impl TestCurrentDate { }), ); - for key in &self.key_list { - let key = ctx.eval_value(key); + for pattern in &self.key_list { + let key = ctx.eval_value(pattern); if match &self.match_type { MatchType::Is => self.comparator.is(&date_part.as_str(), &key), @@ -200,9 +205,12 @@ impl TestCurrentDate { *capture_positions, &mut captured_values, ), - MatchType::Regex(capture_positions) => self.comparator.matches( + // inbuxa: :regex compared as a wildcard pattern + MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, + pattern, + &key, &date_part, - key.to_string().as_ref(), *capture_positions, &mut captured_values, ), diff --git a/vendor/sieve-rs/src/runtime/tests/test_string.rs b/vendor/sieve-rs/src/runtime/tests/test_string.rs index 7bfa3af2..2229099a 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_string.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_string.rs @@ -61,7 +61,10 @@ impl TestString { let mut captured_values = Vec::new(); let sources = ctx.eval_values(&self.source); - for pattern in &self.key_list { + // inbuxa: any key matching any source makes the test true + // (RFC 5229); a plain break only left the inner loop, and the + // next key overwrote the result + 'keys: for pattern in &self.key_list { let key = ctx.eval_value(pattern); for source in &sources { if !empty_is_null || !source.is_empty() { @@ -95,7 +98,7 @@ impl TestString { }; if result { - break; + break 'keys; } } } -- 2.54.0