fix(filter): scope rules to account; fix UTF-8 offsets and NOT IN
- Enforce a rule's account scope in evaluate, evaluate_with_timeout, and preview via matches_account; previously a rule created ON ACCOUNT one account would fire on any account whose message matched the WHERE clause. - Return true byte offsets from find_keyword_outside_quotes (char_indices) instead of a char count, so the WHERE/ACTION and ON ACCOUNT splits no longer corrupt or panic on non-ASCII text preceding a keyword. - Add FilterOperator::NotIn evaluated as the complement of IN. Negated IN lists previously mapped to NotEquals over a List value, which no eval arm handled, so NOT IN was silently always false. Refs #177
This commit is contained in:
3 files changed
+270
-16
No files matched your search
@@ -91,6 +91,8 @@ pub enum FilterOperator {
|
||||
LessThanOrEqual,
|
||||
/// In set (`IN`).
|
||||
In,
|
||||
/// Not in set (`NOT IN`); the logical complement of `In`.
|
||||
NotIn,
|
||||
}
|
||||
|
||||
impl FilterOperator {
|
||||
@@ -107,6 +109,7 @@ impl FilterOperator {
|
||||
Self::GreaterThanOrEqual => ">=",
|
||||
Self::LessThanOrEqual => "<=",
|
||||
Self::In => "IN",
|
||||
Self::NotIn => "NOT IN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ impl CompiledFilter {
|
||||
}
|
||||
|
||||
/// Evaluate email message against compiled rule condition tree.
|
||||
///
|
||||
/// This checks only the WHERE-clause conditions; it does NOT apply the
|
||||
/// rule's account scope. Callers must gate on
|
||||
/// [`FilterRule::matches_account`] first (as every `FilterEngine` entry
|
||||
/// point does), or a rule scoped to one account will fire on messages of
|
||||
/// another.
|
||||
pub fn evaluate_condition(&self, email: &Email) -> bool {
|
||||
Self::eval_node(&self.rule.conditions, email, &self.compiled_regexes)
|
||||
}
|
||||
@@ -183,10 +189,18 @@ impl CompiledFilter {
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
FilterOperator::In => match val {
|
||||
FilterValue::List(list) => list.iter().any(|s| haystack.eq_ignore_ascii_case(s)),
|
||||
_ => false,
|
||||
},
|
||||
FilterOperator::In => Self::eval_in_set(haystack, val),
|
||||
// NOT IN is defined as the exact complement of IN: an empty set
|
||||
// makes IN always false, so NOT IN is always true, and any value
|
||||
// shape that IN cannot match against also makes NOT IN true.
|
||||
FilterOperator::NotIn => !Self::eval_in_set(haystack, val),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_in_set(haystack: &str, val: &FilterValue) -> bool {
|
||||
match val {
|
||||
FilterValue::List(list) => list.iter().any(|s| haystack.eq_ignore_ascii_case(s)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -234,12 +248,17 @@ impl FilterEngine {
|
||||
}
|
||||
|
||||
/// Evaluate email message returning matching rule actions.
|
||||
///
|
||||
/// A rule only ever fires for the account it was created against
|
||||
/// (`FilterRule::target_account`, set via `ON ACCOUNT` or `*` for all
|
||||
/// accounts) — condition matching alone is not account-scoped, so this
|
||||
/// check must happen before `evaluate_condition` runs.
|
||||
pub fn evaluate(&self, email: &Email) -> Vec<(FilterRule, Vec<RuleAction>)> {
|
||||
let guard = self.cache.load();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for filter in &guard.filters {
|
||||
if filter.evaluate_condition(email) {
|
||||
if filter.rule.matches_account(&email.account_id) && filter.evaluate_condition(email) {
|
||||
results.push((filter.rule.clone(), filter.rule.actions.clone()));
|
||||
}
|
||||
}
|
||||
@@ -260,7 +279,9 @@ impl FilterEngine {
|
||||
let guard = engine_cache.load();
|
||||
let mut results = Vec::new();
|
||||
for filter in &guard.filters {
|
||||
if filter.evaluate_condition(&email_clone) {
|
||||
if filter.rule.matches_account(&email_clone.account_id)
|
||||
&& filter.evaluate_condition(&email_clone)
|
||||
{
|
||||
results.push((filter.rule.clone(), filter.rule.actions.clone()));
|
||||
}
|
||||
}
|
||||
@@ -271,6 +292,10 @@ impl FilterEngine {
|
||||
}
|
||||
|
||||
/// Dry-run preview evaluation returning detailed microsecond traces.
|
||||
///
|
||||
/// Rules whose account scope does not match the message are reported as
|
||||
/// `SKIPPED (account scope mismatch)` rather than omitted, so the trace
|
||||
/// distinguishes "condition did not match" from "rule does not apply here".
|
||||
pub fn preview(&self, email: &Email) -> FilterPreviewResult {
|
||||
let start = Instant::now();
|
||||
let guard = self.cache.load();
|
||||
@@ -281,6 +306,14 @@ impl FilterEngine {
|
||||
let mut matched = false;
|
||||
|
||||
for filter in &guard.filters {
|
||||
if !filter.rule.matches_account(&email.account_id) {
|
||||
traces.push(format!(
|
||||
"Rule '{}' (priority {}): SKIPPED (account scope mismatch)",
|
||||
filter.rule.name, filter.rule.priority
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_match = filter.evaluate_condition(email);
|
||||
traces.push(format!(
|
||||
"Rule '{}' (priority {}): {}",
|
||||
@@ -328,6 +361,22 @@ mod tests {
|
||||
use super::*;
|
||||
use nuncio_core::model::Email;
|
||||
|
||||
fn test_email(account_id: &str, subject: &str, folder_id: &str) -> Email {
|
||||
Email {
|
||||
id: "msg-1".to_string(),
|
||||
account_id: account_id.to_string(),
|
||||
folder_id: folder_id.to_string(),
|
||||
subject: subject.to_string(),
|
||||
sender: "alice@nuncio.mx".to_string(),
|
||||
recipient: "bob@nuncio.mx".to_string(),
|
||||
received_at: 1700000000,
|
||||
read: false,
|
||||
body_plain: Some("Hello".to_string()),
|
||||
body_html: None,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_evaluate_and_lockfree_reload() {
|
||||
let nsql = "WHERE subject CONTAINS 'Urgent' ACTION MARK READ, MOVE TO 'Priority'";
|
||||
@@ -363,4 +412,133 @@ mod tests {
|
||||
FilterEngine::sign_webhook_payload("secret123", 1700000000, "{\"event\":\"mail\"}");
|
||||
assert!(sig.starts_with("t=1700000000,v1="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_scopes_rule_to_its_owning_account() {
|
||||
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
|
||||
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
// Condition matches the subject, but the message belongs to a
|
||||
// different account than the rule was created for.
|
||||
let email_b = test_email("acct-b", "Weekly Report", "inbox");
|
||||
assert!(
|
||||
engine.evaluate(&email_b).is_empty(),
|
||||
"rule scoped to acct-a must not match a message from acct-b"
|
||||
);
|
||||
|
||||
let email_a = test_email("acct-a", "Weekly Report", "inbox");
|
||||
assert_eq!(engine.evaluate(&email_a).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_evaluate_with_timeout_scopes_rule_to_its_owning_account() {
|
||||
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
|
||||
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let email_b = test_email("acct-b", "Weekly Report", "inbox");
|
||||
let results = engine
|
||||
.evaluate_with_timeout(&email_b, Duration::from_secs(1))
|
||||
.await;
|
||||
assert!(results.is_empty());
|
||||
|
||||
let email_a = test_email("acct-a", "Weekly Report", "inbox");
|
||||
let results = engine
|
||||
.evaluate_with_timeout(&email_a, Duration::from_secs(1))
|
||||
.await;
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_reports_account_scope_mismatch() {
|
||||
let nsql = "ON ACCOUNT 'acct-a' WHERE subject CONTAINS 'Report' ACTION MARK READ";
|
||||
let rule = crate::parser::NsqlParser::parse_rule("Scoped To A", 1, nsql).unwrap();
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let email_b = test_email("acct-b", "Weekly Report", "inbox");
|
||||
let preview = engine.preview(&email_b);
|
||||
assert!(!preview.matched);
|
||||
assert!(preview
|
||||
.condition_traces
|
||||
.iter()
|
||||
.any(|t| t.contains("account scope mismatch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_account_rule_still_matches_any_account() {
|
||||
let nsql = "WHERE subject CONTAINS 'Report' ACTION MARK READ";
|
||||
let rule = crate::parser::NsqlParser::parse_rule("Global", 1, nsql).unwrap();
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let email = test_email("any-account", "Weekly Report", "inbox");
|
||||
assert_eq!(engine.evaluate(&email).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_in_operator_is_complement_of_in() {
|
||||
let nsql = "WHERE folder NOT IN ('spam', 'trash') ACTION MARK READ";
|
||||
let rule = crate::parser::NsqlParser::parse_rule("Not In Spam Or Trash", 1, nsql).unwrap();
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let inbox_email = test_email("acct-1", "Anything", "inbox");
|
||||
assert_eq!(engine.evaluate(&inbox_email).len(), 1);
|
||||
|
||||
let spam_email = test_email("acct-1", "Anything", "spam");
|
||||
assert!(engine.evaluate(&spam_email).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_in_empty_set_always_matches() {
|
||||
// NOT IN () is the complement of IN (), and IN against an empty set
|
||||
// never matches anything, so NOT IN () must always evaluate true.
|
||||
let rule = FilterRule {
|
||||
id: "r1".to_string(),
|
||||
name: "Empty Set".to_string(),
|
||||
target_account: "*".to_string(),
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
nsql_text: String::new(),
|
||||
conditions: ConditionNode::Leaf(ConditionLeaf {
|
||||
field: FilterField::Folder,
|
||||
operator: FilterOperator::NotIn,
|
||||
value: FilterValue::List(vec![]),
|
||||
}),
|
||||
actions: vec![RuleAction::MarkRead],
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let email = test_email("acct-1", "Anything", "inbox");
|
||||
assert_eq!(engine.evaluate(&email).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_in_against_non_list_value_matches_as_in_complement() {
|
||||
// A leaf carrying a non-list value under NOT IN is a degenerate shape
|
||||
// that IN can never match (see `eval_in_set`); NOT IN, defined as
|
||||
// IN's exact complement, must therefore always match it.
|
||||
let leaf = ConditionLeaf {
|
||||
field: FilterField::Subject,
|
||||
operator: FilterOperator::NotIn,
|
||||
value: FilterValue::String("not-a-list".to_string()),
|
||||
};
|
||||
let rule = FilterRule {
|
||||
id: "r1".to_string(),
|
||||
name: "Null Case".to_string(),
|
||||
target_account: "*".to_string(),
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
nsql_text: String::new(),
|
||||
conditions: ConditionNode::Leaf(leaf),
|
||||
actions: vec![RuleAction::MarkRead],
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
let engine = FilterEngine::new(vec![rule]).unwrap();
|
||||
|
||||
let email = test_email("acct-1", "Anything", "inbox");
|
||||
assert_eq!(engine.evaluate(&email).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,7 @@ impl NsqlParser {
|
||||
}
|
||||
}
|
||||
let op = if *negated {
|
||||
FilterOperator::NotEquals
|
||||
FilterOperator::NotIn
|
||||
} else {
|
||||
FilterOperator::In
|
||||
};
|
||||
@@ -546,26 +546,35 @@ fn preprocess_nsql_where(input: &str) -> String {
|
||||
fn find_keyword_outside_quotes(text: &str, keyword: &str) -> Option<usize> {
|
||||
let mut in_single_quote = false;
|
||||
let mut in_double_quote = false;
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
// Pair each char with its byte offset in `text`. Callers slice `text`
|
||||
// directly with the returned index, so the index must be a byte offset;
|
||||
// a plain `chars().collect()` char count silently diverges from the byte
|
||||
// offset (and can land mid-character) as soon as the text contains any
|
||||
// multi-byte UTF-8 character, e.g. a non-ASCII subject/account literal.
|
||||
let indexed_chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
let kw_chars: Vec<char> = keyword.chars().collect();
|
||||
|
||||
for i in 0..chars.len() {
|
||||
let c = chars[i];
|
||||
for i in 0..indexed_chars.len() {
|
||||
let (byte_idx, c) = indexed_chars[i];
|
||||
if c == '\'' && !in_double_quote {
|
||||
in_single_quote = !in_single_quote;
|
||||
} else if c == '"' && !in_single_quote {
|
||||
in_double_quote = !in_double_quote;
|
||||
} else if !in_single_quote && !in_double_quote && i + kw_chars.len() <= chars.len() {
|
||||
let is_match = chars[i..i + kw_chars.len()]
|
||||
} else if !in_single_quote && !in_double_quote && i + kw_chars.len() <= indexed_chars.len()
|
||||
{
|
||||
let is_match = indexed_chars[i..i + kw_chars.len()]
|
||||
.iter()
|
||||
.zip(kw_chars.iter())
|
||||
.all(|(a, b)| a.eq_ignore_ascii_case(b));
|
||||
.all(|((_, a), b)| a.eq_ignore_ascii_case(b));
|
||||
if is_match {
|
||||
let prev_ok = i == 0 || chars[i - 1].is_whitespace() || chars[i - 1] == ')';
|
||||
let prev_ok = i == 0
|
||||
|| indexed_chars[i - 1].1.is_whitespace()
|
||||
|| indexed_chars[i - 1].1 == ')';
|
||||
let next_idx = i + kw_chars.len();
|
||||
let next_ok = next_idx == chars.len() || chars[next_idx].is_whitespace();
|
||||
let next_ok =
|
||||
next_idx == indexed_chars.len() || indexed_chars[next_idx].1.is_whitespace();
|
||||
if prev_ok && next_ok {
|
||||
return Some(i);
|
||||
return Some(byte_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,4 +639,68 @@ mod tests {
|
||||
assert_eq!(rule.target_account, "*");
|
||||
assert_eq!(rule.actions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_not_in_operator() {
|
||||
let nsql = "WHERE folder NOT IN ('Spam', 'Trash') ACTION DELETE";
|
||||
let rule = NsqlParser::parse_rule("Not In Rule", 1, nsql).expect("parse rule");
|
||||
match &rule.conditions {
|
||||
ConditionNode::Leaf(leaf) => {
|
||||
assert_eq!(leaf.operator, FilterOperator::NotIn);
|
||||
assert_eq!(
|
||||
leaf.value,
|
||||
FilterValue::List(vec!["Spam".to_string(), "Trash".to_string()])
|
||||
);
|
||||
}
|
||||
other => panic!("expected leaf condition, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_in_operator_still_distinct_from_not_in() {
|
||||
let nsql = "WHERE folder IN ('Inbox', 'Priority') ACTION MARK READ";
|
||||
let rule = NsqlParser::parse_rule("In Rule", 1, nsql).expect("parse rule");
|
||||
match &rule.conditions {
|
||||
ConditionNode::Leaf(leaf) => assert_eq!(leaf.operator, FilterOperator::In),
|
||||
other => panic!("expected leaf condition, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_split_survives_multibyte_utf8_before_action_keyword() {
|
||||
// Regression test: `find_keyword_outside_quotes` used to return a
|
||||
// *char* index that callers then used as a *byte* index to slice the
|
||||
// source text. Any multi-byte UTF-8 character ahead of the ACTION
|
||||
// keyword desynchronized that offset, corrupting the WHERE/ACTION
|
||||
// split (or panicking on a non-UTF-8-boundary slice).
|
||||
let nsql = "WHERE subject = '日本語のテスト' ACTION MARK READ";
|
||||
let rule = NsqlParser::parse_rule("Multibyte Subject", 1, nsql)
|
||||
.expect("parse rule with multi-byte utf8 subject");
|
||||
|
||||
assert_eq!(rule.actions, vec![RuleAction::MarkRead]);
|
||||
match &rule.conditions {
|
||||
ConditionNode::Leaf(leaf) => {
|
||||
assert_eq!(leaf.field, FilterField::Subject);
|
||||
assert_eq!(
|
||||
leaf.value,
|
||||
FilterValue::String("日本語のテスト".to_string())
|
||||
);
|
||||
}
|
||||
other => panic!("expected leaf condition, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_account_split_survives_multibyte_utf8_before_where_keyword() {
|
||||
let nsql = "ON ACCOUNT 'jörg@kof22.com' WHERE subject = '日本語' ACTION MARK READ";
|
||||
let rule = NsqlParser::parse_rule("Multibyte Account", 1, nsql).expect("parse rule");
|
||||
|
||||
assert_eq!(rule.target_account, "jörg@kof22.com");
|
||||
match &rule.conditions {
|
||||
ConditionNode::Leaf(leaf) => {
|
||||
assert_eq!(leaf.value, FilterValue::String("日本語".to_string()));
|
||||
}
|
||||
other => panic!("expected leaf condition, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user