From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 38C811FF0E0 for ; Thu, 09 Jul 2026 13:58:33 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 61CBB2157D; Thu, 09 Jul 2026 13:57:57 +0200 (CEST) From: Lukas Wagner To: pbs-devel@lists.proxmox.com, pve-devel@lists.proxmox.com Subject: [PATCH proxmox 05/29] notify: matcher: break out field matcher into submodule Date: Thu, 9 Jul 2026 13:56:52 +0200 Message-ID: <20260709115716.299836-6-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709115716.299836-1-l.wagner@proxmox.com> References: <20260709115716.299836-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1783598238088 X-SPAM-LEVEL: Spam detection results: 0 DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: HDAEWKIPA6VQ56XKFNVCUVOYB4DZEK5H X-Message-ID-Hash: HDAEWKIPA6VQ56XKFNVCUVOYB4DZEK5H X-MailFrom: l.wagner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: No functional changes. Signed-off-by: Lukas Wagner --- proxmox-notify/src/matcher/field.rs | 201 ++++++++++++++++++++++++++++ proxmox-notify/src/matcher/mod.rs | 184 +------------------------ 2 files changed, 205 insertions(+), 180 deletions(-) create mode 100644 proxmox-notify/src/matcher/field.rs diff --git a/proxmox-notify/src/matcher/field.rs b/proxmox-notify/src/matcher/field.rs new file mode 100644 index 00000000..8165b1f4 --- /dev/null +++ b/proxmox-notify/src/matcher/field.rs @@ -0,0 +1,201 @@ +use std::{fmt, str::FromStr}; + +use const_format::concatcp; +use regex::Regex; + +use proxmox_schema::{ + ApiStringFormat, Schema, StringSchema, api_types::SAFE_ID_REGEX_STR, const_regex, +}; + +use crate::{Error, Notification}; + +use super::MatchDirective; + +const_regex! { + pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$"); +} + +pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat = + ApiStringFormat::VerifyFn(verify_field_matcher); + +fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> { + let _: FieldMatcher = s.parse()?; + Ok(()) +} + +pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.") + .format(&MATCH_FIELD_ENTRY_FORMAT) + .min_length(1) + .max_length(1024) + .schema(); + +/// Check if the notification metadata fields match +#[derive(Clone, Debug)] +pub enum FieldMatcher { + Exact { + field: String, + matched_values: Vec, + }, + Regex { + field: String, + matched_regex: Regex, + }, +} + +impl MatchDirective for FieldMatcher { + fn matches(&self, notification: &Notification) -> Result { + Ok(match self { + FieldMatcher::Exact { + field, + matched_values, + } => { + let value = notification.metadata.additional_fields.get(field); + + if let Some(value) = value { + matched_values.contains(value) + } else { + // Metadata field does not exist, so we do not match + false + } + } + FieldMatcher::Regex { + field, + matched_regex, + } => { + let value = notification.metadata.additional_fields.get(field); + + if let Some(value) = value { + matched_regex.is_match(value) + } else { + // Metadata field does not exist, so we do not match + false + } + } + }) + } +} + +impl fmt::Display for FieldMatcher { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // Attention, Display is used to implement Serialize, do not + // change the format. + + match self { + FieldMatcher::Exact { + field, + matched_values, + } => { + let values = matched_values.join(","); + write!(f, "exact:{field}={values}") + } + FieldMatcher::Regex { + field, + matched_regex, + } => { + let re = matched_regex.as_str(); + write!(f, "regex:{field}={re}") + } + } + } +} + +impl FromStr for FieldMatcher { + type Err = Error; + fn from_str(s: &str) -> Result { + if !MATCH_FIELD_ENTRY_REGEX.is_match(s) { + return Err(Error::FilterFailed(format!( + "invalid match-field statement: {s}" + ))); + } + + if let Some(remaining) = s.strip_prefix("regex:") { + match remaining.split_once('=') { + None => Err(Error::FilterFailed(format!( + "invalid match-field statement: {s}" + ))), + Some((field, expected_value_regex)) => { + let regex = Regex::new(expected_value_regex) + .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?; + + Ok(Self::Regex { + field: field.into(), + matched_regex: regex, + }) + } + } + } else if let Some(remaining) = s.strip_prefix("exact:") { + match remaining.split_once('=') { + None => Err(Error::FilterFailed(format!( + "invalid match-field statement: {s}" + ))), + Some((field, expected_values)) => { + let values: Vec = expected_values + .split(',') + .map(str::trim) + .map(String::from) + .collect(); + Ok(Self::Exact { + field: field.into(), + matched_values: values, + }) + } + } + } else { + Err(Error::FilterFailed(format!( + "invalid match-field statement: {s}" + ))) + } + } +} + +proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher); +proxmox_serde::forward_serialize_to_display!(FieldMatcher); + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::Value; + + use crate::Severity; + + use super::*; + + #[test] + fn test_matching() { + let mut fields = HashMap::new(); + fields.insert("foo".into(), "bar".into()); + + let notification = + Notification::from_template(Severity::Notice, "test", Value::Null, fields); + + let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap(); + assert!(matcher.matches(¬ification).unwrap()); + + let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap(); + assert!(matcher.matches(¬ification).unwrap()); + + let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap(); + assert!(!matcher.matches(¬ification).unwrap()); + + let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap(); + assert!(matcher.matches(¬ification).unwrap()); + + let mut fields = HashMap::new(); + fields.insert("foo".into(), "test".into()); + + let notification = + Notification::from_template(Severity::Notice, "test", Value::Null, fields); + assert!(matcher.matches(¬ification).unwrap()); + + let mut fields = HashMap::new(); + fields.insert("foo".into(), "notthere".into()); + + let notification = + Notification::from_template(Severity::Notice, "test", Value::Null, fields); + assert!(!matcher.matches(¬ification).unwrap()); + + assert!("regex:'3=b.*".parse::().is_err()); + assert!("invalid:'bar=b.*".parse::().is_err()); + } +} diff --git a/proxmox-notify/src/matcher/mod.rs b/proxmox-notify/src/matcher/mod.rs index d27ce465..dce9ec5c 100644 --- a/proxmox-notify/src/matcher/mod.rs +++ b/proxmox-notify/src/matcher/mod.rs @@ -15,8 +15,10 @@ use proxmox_time::{DailyDuration, parse_daily_duration}; use crate::schema::ENTITY_NAME_SCHEMA; use crate::{Error, Notification, Origin}; +pub mod field; pub mod severity; +use field::FieldMatcher; use severity::SeverityMatcher; pub const MATCHER_TYPENAME: &str = "matcher"; @@ -51,24 +53,6 @@ impl MatchModeOperator { } } -const_regex! { - pub MATCH_FIELD_ENTRY_REGEX = concatcp!(r"^(?:(exact|regex):)?(", SAFE_ID_REGEX_STR, r")=(.*)$"); -} - -pub const MATCH_FIELD_ENTRY_FORMAT: ApiStringFormat = - ApiStringFormat::VerifyFn(verify_field_matcher); - -fn verify_field_matcher(s: &str) -> Result<(), anyhow::Error> { - let _: FieldMatcher = s.parse()?; - Ok(()) -} - -pub const MATCH_FIELD_ENTRY_SCHEMA: Schema = StringSchema::new("Match metadata field.") - .format(&MATCH_FIELD_ENTRY_FORMAT) - .min_length(1) - .max_length(1024) - .schema(); - #[api( properties: { name: { @@ -163,128 +147,6 @@ trait MatchDirective { fn matches(&self, notification: &Notification) -> Result; } -/// Check if the notification metadata fields match -#[derive(Clone, Debug)] -pub enum FieldMatcher { - Exact { - field: String, - matched_values: Vec, - }, - Regex { - field: String, - matched_regex: Regex, - }, -} - -proxmox_serde::forward_deserialize_to_from_str!(FieldMatcher); -proxmox_serde::forward_serialize_to_display!(FieldMatcher); - -impl MatchDirective for FieldMatcher { - fn matches(&self, notification: &Notification) -> Result { - Ok(match self { - FieldMatcher::Exact { - field, - matched_values, - } => { - let value = notification.metadata.additional_fields.get(field); - - if let Some(value) = value { - matched_values.contains(value) - } else { - // Metadata field does not exist, so we do not match - false - } - } - FieldMatcher::Regex { - field, - matched_regex, - } => { - let value = notification.metadata.additional_fields.get(field); - - if let Some(value) = value { - matched_regex.is_match(value) - } else { - // Metadata field does not exist, so we do not match - false - } - } - }) - } -} - -impl fmt::Display for FieldMatcher { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - // Attention, Display is used to implement Serialize, do not - // change the format. - - match self { - FieldMatcher::Exact { - field, - matched_values, - } => { - let values = matched_values.join(","); - write!(f, "exact:{field}={values}") - } - FieldMatcher::Regex { - field, - matched_regex, - } => { - let re = matched_regex.as_str(); - write!(f, "regex:{field}={re}") - } - } - } -} - -impl FromStr for FieldMatcher { - type Err = Error; - fn from_str(s: &str) -> Result { - if !MATCH_FIELD_ENTRY_REGEX.is_match(s) { - return Err(Error::FilterFailed(format!( - "invalid match-field statement: {s}" - ))); - } - - if let Some(remaining) = s.strip_prefix("regex:") { - match remaining.split_once('=') { - None => Err(Error::FilterFailed(format!( - "invalid match-field statement: {s}" - ))), - Some((field, expected_value_regex)) => { - let regex = Regex::new(expected_value_regex) - .map_err(|err| Error::FilterFailed(format!("invalid regex: {err}")))?; - - Ok(Self::Regex { - field: field.into(), - matched_regex: regex, - }) - } - } - } else if let Some(remaining) = s.strip_prefix("exact:") { - match remaining.split_once('=') { - None => Err(Error::FilterFailed(format!( - "invalid match-field statement: {s}" - ))), - Some((field, expected_values)) => { - let values: Vec = expected_values - .split(',') - .map(str::trim) - .map(String::from) - .collect(); - Ok(Self::Exact { - field: field.into(), - matched_values: values, - }) - } - } - } else { - Err(Error::FilterFailed(format!( - "invalid match-field statement: {s}" - ))) - } - } -} - impl MatcherConfig { pub fn matches(&self, notification: &Notification) -> Result, Error> { let mode = self.mode.unwrap_or_default(); @@ -428,49 +290,11 @@ pub fn check_matches<'a>( #[cfg(test)] mod tests { + use serde_json::Value; + use crate::Severity; use super::*; - use serde_json::Value; - use std::collections::HashMap; - - #[test] - fn test_matching() { - let mut fields = HashMap::new(); - fields.insert("foo".into(), "bar".into()); - - let notification = - Notification::from_template(Severity::Notice, "test", Value::Null, fields); - - let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap(); - assert!(matcher.matches(¬ification).unwrap()); - - let matcher: FieldMatcher = "regex:foo=b.*".parse().unwrap(); - assert!(matcher.matches(¬ification).unwrap()); - - let matcher: FieldMatcher = "regex:notthere=b.*".parse().unwrap(); - assert!(!matcher.matches(¬ification).unwrap()); - - let matcher: FieldMatcher = "exact:foo=bar,test".parse().unwrap(); - assert!(matcher.matches(¬ification).unwrap()); - - let mut fields = HashMap::new(); - fields.insert("foo".into(), "test".into()); - - let notification = - Notification::from_template(Severity::Notice, "test", Value::Null, fields); - assert!(matcher.matches(¬ification).unwrap()); - - let mut fields = HashMap::new(); - fields.insert("foo".into(), "notthere".into()); - - let notification = - Notification::from_template(Severity::Notice, "test", Value::Null, fields); - assert!(!matcher.matches(¬ification).unwrap()); - - assert!("regex:'3=b.*".parse::().is_err()); - assert!("invalid:'bar=b.*".parse::().is_err()); - } #[test] fn test_empty_matcher_matches_always() { -- 2.47.3