fix(notify): Fix XML Filter parsing and add comprehensive tests (#2191)

This commit is contained in:
houseme
2026-03-17 23:08:07 +08:00
committed by GitHub
parent ce1f7cfdcb
commit b5d881f399
9 changed files with 1208 additions and 24 deletions
Generated
+1
View File
@@ -6596,6 +6596,7 @@ version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"encoding_rs",
"memchr",
"serde",
"tokio",
+7 -1
View File
@@ -37,7 +37,6 @@ chrono = { workspace = true, features = ["serde"] }
futures = { workspace = true }
form_urlencoded = { workspace = true }
hashbrown = { workspace = true }
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
rayon = { workspace = true }
rumqttc = { workspace = true }
rustc-hash = { workspace = true }
@@ -49,6 +48,13 @@ tracing = { workspace = true }
url = { workspace = true }
wildmatch = { workspace = true, features = ["serde"] }
# quick-xml dependencies for custom S3KeyFilter XML deserialization
# Custom deserializer implemented for S3KeyFilter to handle both XML structures:
# - Direct FilterRule elements under S3Key (AWS S3 format)
# - FilterRuleList wrapper format
# This makes quick-xml 0.39.2 work correctly with Filter elements
quick-xml = { workspace = true, features = ["serialize", "serde-types", "encoding"] }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
+5 -9
View File
@@ -111,15 +111,11 @@ pub mod notifier_global {
suffix: &str,
target_ids: &[TargetID],
) -> Result<(), NotificationError> {
// Construct pattern, simple splicing of prefixes and suffixes
let mut pattern = String::new();
if !prefix.is_empty() {
pattern.push_str(prefix);
}
pattern.push('*');
if !suffix.is_empty() {
pattern.push_str(suffix);
}
// Construct pattern using proper pattern function
let pattern = crate::rules::pattern::new_pattern(
if prefix.is_empty() { None } else { Some(prefix) },
if suffix.is_empty() { None } else { Some(suffix) },
);
// Create BucketNotificationConfig
let mut bucket_config = BucketNotificationConfig::new(region);
+1 -1
View File
@@ -117,7 +117,7 @@ impl BucketNotificationConfig {
// The ARN in queue_conf should now have its region set if it was originally empty.
// Ensure TargetID can be cloned or extracted correctly.
let target_id = queue_conf.arn.target_id.clone();
let pattern_str = queue_conf.filter.filter_rule_list.pattern();
let pattern_str = queue_conf.filter.pattern();
rules_map.add_rule_config(&queue_conf.events, pattern_str, target_id);
}
+440
View File
@@ -0,0 +1,440 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Integration tests for BucketNotificationConfig
//!
//! This module contains tests that simulate the exact flow from XML configuration
//! to event matching, including filter rules with prefix and suffix.
use super::*;
use rustfs_s3_common::EventName;
use rustfs_targets::arn::{ARN, TargetID};
use std::io::Cursor;
#[cfg(test)]
mod integration_tests {
use super::*;
fn create_test_arn() -> ARN {
ARN {
partition: "rustfs".to_string(),
service: "sqs".to_string(),
region: "".to_string(),
target_id: TargetID::new("primary".to_string(), "webhook".to_string()),
}
}
#[test]
fn test_create_arn() {
let arn = create_test_arn();
assert_eq!(arn.partition, "rustfs");
assert_eq!(arn.service, "sqs");
assert_eq!(arn.region, "");
assert_eq!(arn.target_id, TargetID::new("primary".to_string(), "webhook".to_string()));
}
/// Test exact bug scenario from the bug report
#[test]
fn test_bug_report_exact_scenario_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRule>
<Name>prefix</Name>
<Value>uploads/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.csv</Value>
</FilterRule>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
// Verify pattern is "uploads/*.csv"
let rules_map = config.get_rules_map();
// Debug: Print the patterns stored
println!("\nPatterns stored in rules_map:");
for (event_name, pattern_rules) in rules_map.inner().iter() {
for pattern in pattern_rules.inner().keys() {
println!(" Event: {:?}, Pattern: '{}'", event_name, pattern);
}
}
// The event should be registered for ObjectCreatedAll and all its expansions
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedAll));
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
// Test matching "uploads/test.csv" with ObjectCreatedPut
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
println!("Matching 'uploads/test.csv' with ObjectCreatedPut: {} targets", targets.len());
for target in &targets {
println!(" Target: {:?}", target);
}
// Should find the target
assert!(!targets.is_empty(), "Should find at least one target");
// Test matching "uploads/subdir/file.csv" - should also match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/subdir/file.csv");
assert!(!targets.is_empty(), "Nested CSV files should match");
// Test matching "uploads/test.txt" - should NOT match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
assert!(targets.is_empty(), "TXT files should not match");
// Test matching "files/test.csv" - should NOT match (wrong prefix)
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "files/test.csv");
assert!(targets.is_empty(), "Files in wrong prefix should not match");
}
/// Test prefix only filter
#[test]
fn test_prefix_only_filter_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRuleList>
<FilterRule>
<Name>prefix</Name>
<Value>images/</Value>
</FilterRule>
</FilterRuleList>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// Debug: Print the patterns stored
println!("\nPatterns stored in rules_map:");
for (event_name, pattern_rules) in rules_map.inner().iter() {
for pattern in pattern_rules.inner().keys() {
println!(" Event: {:?}, Pattern: '{}'", event_name, pattern);
}
}
// Test matching "images/photo.jpg"
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
println!("Matching 'images/photo.jpg': {} targets", targets.len());
assert!(!targets.is_empty(), "Files in images/ should match");
// Test matching "uploads/photo.jpg" - should NOT match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/photo.jpg");
println!("Matching 'uploads/photo.jpg': {} targets", targets.len());
assert!(targets.is_empty(), "Files not in images/ should not match");
}
/// Test suffix only filter
#[test]
fn test_suffix_only_filter_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRuleList>
<FilterRule>
<Name>suffix</Name>
<Value>.pdf</Value>
</FilterRule>
</FilterRuleList>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// Test matching "document.pdf" - should match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.pdf");
assert!(!targets.is_empty(), "PDF files should match");
// Test matching "document.txt" - should NOT match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.txt");
assert!(targets.is_empty(), "Non-PDF files should not match");
}
/// Test no filter (match all)
#[test]
fn test_no_filter_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// All files should match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "anything.csv");
assert!(!targets.is_empty(), "All files should match");
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
assert!(!targets.is_empty(), "All files should match");
}
/// Test specific event type instead of compound
#[test]
fn test_specific_event_type_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:Put</Event>
<Filter>
<S3Key>
<FilterRule>
<Name>prefix</Name>
<Value>uploads/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.csv</Value>
</FilterRule>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// Debug: Print all events that have subscribers
println!("\nAll events with subscribers:");
for (event_name, _pattern_rules) in rules_map.inner().iter() {
println!(" Event: {:?}", event_name);
}
// Note: has_subscriber uses bitmask logic, so has_subscriber(&ObjectCreatedAll) returns true
// if any ObjectCreated* event is registered. This is intentional for efficiency.
// Only ObjectCreatedPut was explicitly registered in the XML.
// Should have subscriber for ObjectCreatedPut
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
// Should NOT have subscriber for ObjectCreatedPost (only Put was configured)
assert!(!rules_map.has_subscriber(&EventName::ObjectCreatedPost));
// Test matching with Put event
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
assert!(!targets.is_empty(), "Put event should match");
}
/// Test URL-encoded object keys
#[test]
fn test_url_encoded_keys() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRule>
<Name>prefix</Name>
<Value>uploads/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.csv</Value>
</FilterRule>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// Test with URL-encoded space: "uploads/my%20file.csv"
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/my%20file.csv");
// This may or may not match depending on encoding
println!("URL-encoded key 'uploads/my%20file.csv' matched: {}", !targets.is_empty());
// Test with unencoded version
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/my file.csv");
assert!(!targets.is_empty(), "Unencoded key should match");
}
/// Test multiple queue configurations
#[test]
fn test_multiple_queue_configs_xml() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>csv-queue</Id>
<Queue>arn:rustfs:sqs::primary:webhook-csv</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRule>
<Name>prefix</Name>
<Value>uploads/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.csv</Value>
</FilterRule>
</S3Key>
</Filter>
</QueueConfiguration>
<QueueConfiguration>
<Id>jpg-queue</Id>
<Queue>arn:rustfs:sqs::primary:webhook-jpg</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRuleList>
<FilterRule>
<Name>prefix</Name>
<Value>images/</Value>
</FilterRule>
<FilterRule>
<Name>suffix</Name>
<Value>.jpg</Value>
</FilterRule>
</FilterRuleList>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec![
"arn:rustfs:sqs:ap-northeast-1:primary:webhook-csv".to_string(),
"arn:rustfs:sqs:ap-northeast-1:primary:webhook-jpg".to_string(),
];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// Test CSV files
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
println!("Targets for 'uploads/test.csv': {:?}", targets);
assert!(!targets.is_empty(), "CSV files should match");
// Should have csv target (ARN: arn:rustfs:sqs::primary:webhook-csv)
let csv_target = TargetID::new("primary".to_string(), "webhook-csv".to_string());
assert!(targets.contains(&csv_target), "Should find CSV webhook, got: {:?}", targets);
// Test JPG files
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
assert!(!targets.is_empty(), "JPG files should match");
// Should have jpg target (ARN: arn:rustfs:sqs:primary:webhook-jpg)
let jpg_target = TargetID::new("primary".to_string(), "webhook-jpg".to_string());
assert!(targets.contains(&jpg_target), "Should find JPG webhook");
}
/// Test compound event type expansion
#[test]
fn test_compound_event_expansion_integration() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<NotificationConfiguration>
<QueueConfiguration>
<Id>test-queue</Id>
<Queue>arn:rustfs:sqs:ap-northeast-1:primary:webhook</Queue>
<Event>s3:ObjectCreated:*</Event>
<Filter>
<S3Key>
<FilterRuleList>
<FilterRule>
<Name>prefix</Name>
<Value>data/</Value>
</FilterRule>
</FilterRuleList>
</S3Key>
</Filter>
</QueueConfiguration>
</NotificationConfiguration>"#;
let current_region = "ap-northeast-1";
let arn_list = vec!["arn:rustfs:sqs:ap-northeast-1:primary:webhook".to_string()];
let config = BucketNotificationConfig::from_xml(Cursor::new(xml.as_bytes()), current_region, &arn_list).unwrap();
let rules_map = config.get_rules_map();
// ObjectCreated:* should be expanded to all ObjectCreated events
let event_types = [
EventName::ObjectCreatedPut,
EventName::ObjectCreatedPost,
EventName::ObjectCreatedCopy,
EventName::ObjectCreatedCompleteMultipartUpload,
EventName::ObjectCreatedPutRetention,
EventName::ObjectCreatedPutLegalHold,
EventName::ObjectCreatedPutTagging,
EventName::ObjectCreatedDeleteTagging,
];
for event_type in event_types {
assert!(rules_map.has_subscriber(&event_type), "Event {:?} should have subscribers", event_type);
// All should match "data/file.csv"
let targets = rules_map.match_rules(event_type, "data/file.csv");
assert!(!targets.is_empty(), "Event {:?} should match", event_type);
}
}
}
+6
View File
@@ -13,8 +13,14 @@
// limitations under the License.
mod config;
#[cfg(test)]
mod config_test;
pub mod pattern;
mod pattern_rules;
#[cfg(test)]
mod pattern_rules_test;
#[cfg(test)]
mod pattern_test;
mod rules_map;
mod subscriber_index;
mod subscriber_snapshot;
@@ -0,0 +1,481 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Tests for PatternRules and RulesMap
//!
//! This module contains comprehensive tests for the rule matching logic
//! used in bucket notifications, specifically testing the flow from
//! configuration to event matching.
use super::*;
use rustfs_s3_common::EventName;
use rustfs_targets::arn::TargetID;
#[cfg(test)]
mod pattern_rules_tests {
use super::*;
/// Test basic pattern rules matching
#[test]
fn test_pattern_rules_basic_matching() {
let mut rules = PatternRules::new();
let target_id = TargetID::new("test-id".to_string(), "webhook".to_string());
// Add a rule for *.csv files
rules.add("*.csv".to_string(), target_id.clone());
// Test matching
assert!(rules.match_simple("test.csv"));
assert!(rules.match_simple("uploads/test.csv"));
assert!(!rules.match_simple("test.txt"));
// Test match_targets
let targets = rules.match_targets("test.csv");
assert!(targets.contains(&target_id));
let targets = rules.match_targets("test.txt");
assert!(!targets.contains(&target_id));
}
/// Test multiple patterns with different targets
#[test]
fn test_multiple_patterns() {
let mut rules = PatternRules::new();
let csv_target = TargetID::new("csv-target".to_string(), "webhook".to_string());
let jpg_target = TargetID::new("jpg-target".to_string(), "webhook".to_string());
rules.add("*.csv".to_string(), csv_target.clone());
rules.add("*.jpg".to_string(), jpg_target.clone());
// CSV files match csv_target
let targets = rules.match_targets("test.csv");
assert!(targets.contains(&csv_target));
assert!(!targets.contains(&jpg_target));
// JPG files match jpg_target
let targets = rules.match_targets("test.jpg");
assert!(!targets.contains(&csv_target));
assert!(targets.contains(&jpg_target));
// Both patterns match for nested paths
let targets = rules.match_targets("uploads/test.csv");
assert!(targets.contains(&csv_target));
let targets = rules.match_targets("images/photo.jpg");
assert!(targets.contains(&jpg_target));
}
/// Test prefix patterns
#[test]
fn test_prefix_patterns() {
let mut rules = PatternRules::new();
let target_id = TargetID::new("prefix-target".to_string(), "webhook".to_string());
rules.add("uploads/*".to_string(), target_id.clone());
rules.add("images/*".to_string(), target_id.clone());
assert!(rules.match_simple("uploads/test.csv"));
assert!(rules.match_simple("uploads/subdir/test.csv"));
assert!(rules.match_simple("images/photo.jpg"));
assert!(!rules.match_simple("documents/test.txt"));
}
/// Test prefix + suffix pattern (bug scenario)
#[test]
fn test_prefix_suffix_pattern_bug_scenario() {
let mut rules = PatternRules::new();
let target_id = TargetID::new("webhook-target".to_string(), "webhook".to_string());
// This is the pattern generated from prefix="uploads/", suffix=".csv"
rules.add("uploads/*.csv".to_string(), target_id.clone());
// These should match
assert!(rules.match_simple("uploads/test.csv"));
assert!(rules.match_simple("uploads/file1.csv"));
assert!(rules.match_simple("uploads/nested/path/file.csv"));
// These should not match
assert!(!rules.match_simple("uploads/test.txt"));
assert!(!rules.match_simple("files/test.csv"));
// Verify match_targets returns correct targets
let targets = rules.match_targets("uploads/test.csv");
assert_eq!(targets.len(), 1);
assert!(targets.contains(&target_id));
}
/// Test match_all pattern
#[test]
fn test_match_all_pattern() {
let mut rules = PatternRules::new();
let target_id = TargetID::new("all-target".to_string(), "webhook".to_string());
rules.add("*".to_string(), target_id.clone());
assert!(rules.match_simple("anything"));
assert!(rules.match_simple("uploads/test.csv"));
assert!(rules.match_simple(""));
let targets = rules.match_targets("any_key");
assert!(targets.contains(&target_id));
}
/// Test empty rules
#[test]
fn test_empty_rules() {
let rules = PatternRules::new();
assert!(rules.is_empty());
assert!(!rules.match_simple("anything"));
let targets = rules.match_targets("test.csv");
assert!(targets.is_empty());
}
/// Test union operation
#[test]
fn test_union() {
let mut rules1 = PatternRules::new();
let mut rules2 = PatternRules::new();
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
rules1.add("*.csv".to_string(), target1.clone());
rules2.add("*.jpg".to_string(), target2.clone());
let combined = rules1.union(&rules2);
assert!(combined.match_simple("test.csv"));
assert!(combined.match_simple("test.jpg"));
assert!(!combined.match_simple("test.png"));
let targets = combined.match_targets("test.csv");
assert!(targets.contains(&target1));
}
/// Test difference operation
#[test]
fn test_difference() {
let mut rules1 = PatternRules::new();
let mut rules2 = PatternRules::new();
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
// Add same target to multiple patterns in rules1
rules1.add("*.csv".to_string(), target1.clone());
rules1.add("*.jpg".to_string(), target1.clone());
rules1.add("*.txt".to_string(), target1.clone());
// Add different target to .jpg pattern in rules2
rules2.add("*.jpg".to_string(), target2.clone());
let diff = rules1.difference(&rules2);
// After difference:
// *.csv: target1 remains (pattern doesn't exist in rules2)
// *.jpg: target1 remains (target1 != target2, so it's not removed)
// *.txt: target1 remains (pattern doesn't exist in rules2)
assert!(diff.match_simple("test.csv"));
assert!(diff.match_simple("test.jpg"));
assert!(diff.match_simple("test.txt"));
}
/// Test difference operation removing same target
#[test]
fn test_difference_same_target() {
let mut rules1 = PatternRules::new();
let mut rules2 = PatternRules::new();
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
// Add same target to multiple patterns in rules1
rules1.add("*.csv".to_string(), target1.clone());
rules1.add("*.jpg".to_string(), target1.clone());
rules1.add("*.txt".to_string(), target1.clone());
// Add same target to .jpg pattern in rules2
rules2.add("*.jpg".to_string(), target1.clone());
let diff = rules1.difference(&rules2);
// After difference:
// *.csv: target1 remains (pattern doesn't exist in rules2)
// *.jpg: target1 is removed (same target in both)
// *.txt: target1 remains (pattern doesn't exist in rules2)
assert!(diff.match_simple("test.csv"));
assert!(!diff.match_simple("test.jpg"));
assert!(diff.match_simple("test.txt"));
}
/// Test remove_pattern
#[test]
fn test_remove_pattern() {
let mut rules = PatternRules::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
rules.add("*.csv".to_string(), target_id.clone());
rules.add("*.jpg".to_string(), target_id.clone());
assert!(rules.match_simple("test.csv"));
assert!(rules.match_simple("test.jpg"));
// Remove .csv pattern
assert!(rules.remove_pattern("*.csv"));
assert!(!rules.match_simple("test.csv"));
assert!(rules.match_simple("test.jpg"));
// Remove non-existent pattern
assert!(!rules.remove_pattern("*.png"));
}
}
#[cfg(test)]
mod rules_map_tests {
use super::*;
/// Test basic rule addition and matching
#[test]
fn test_rules_map_basic() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
// Add rule for ObjectCreatedPut with pattern *
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*".to_string(), target_id.clone());
// Check has_subscriber
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
assert!(!rules_map.has_subscriber(&EventName::ObjectRemovedDelete));
// Check match_rules
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "any_key");
assert!(targets.contains(&target_id));
// Check event with different name doesn't match
let targets = rules_map.match_rules(EventName::ObjectRemovedDelete, "any_key");
assert!(!targets.contains(&target_id));
}
/// Test compound event expansion
#[test]
fn test_compound_event_expansion() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
// Add rule for ObjectCreatedAll (compound event)
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "*.csv".to_string(), target_id.clone());
// ObjectCreatedAll should be expanded to all ObjectCreated events
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedAll));
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPut));
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedPost));
assert!(rules_map.has_subscriber(&EventName::ObjectCreatedCopy));
// All should match .csv files
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
assert!(targets.contains(&target_id));
let targets = rules_map.match_rules(EventName::ObjectCreatedPost, "test.csv");
assert!(targets.contains(&target_id));
}
/// Test exact bug scenario
#[test]
fn test_bug_scenario_exact_flow() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("webhook-primary".to_string(), "webhook".to_string());
// Configuration: Events=["s3:ObjectCreated:*"], prefix="uploads/", suffix=".csv"
// This generates pattern "uploads/*.csv"
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "uploads/*.csv".to_string(), target_id.clone());
// When ObjectCreatedPut event occurs with key "uploads/test.csv"
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.csv");
// Should match
assert!(!targets.is_empty(), "Targets should not be empty for matching event");
assert!(targets.contains(&target_id), "Should find webhook-primary target");
// Test non-matching key
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
assert!(targets.is_empty(), "Targets should be empty for non-matching key");
// Test matching different suffix
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/subdir/file.csv");
assert!(!targets.is_empty(), "Nested CSV files should also match");
assert!(targets.contains(&target_id));
}
/// Test multiple patterns for same event
#[test]
fn test_multiple_patterns_same_event() {
let mut rules_map = RulesMap::new();
let csv_target = TargetID::new("csv-target".to_string(), "webhook".to_string());
let jpg_target = TargetID::new("jpg-target".to_string(), "webhook".to_string());
// Add different patterns for same event
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), csv_target.clone());
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.jpg".to_string(), jpg_target.clone());
// Both patterns should work
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
assert!(targets.contains(&csv_target));
assert!(!targets.contains(&jpg_target));
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.jpg");
assert!(!targets.contains(&csv_target));
assert!(targets.contains(&jpg_target));
}
/// Test prefix only filter
#[test]
fn test_prefix_only_filter() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
// Configuration: prefix="images/", no suffix
// Pattern should be "images/*"
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "images/*".to_string(), target_id.clone());
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "images/photo.jpg");
assert!(targets.contains(&target_id));
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/photo.jpg");
assert!(!targets.contains(&target_id));
}
/// Test suffix only filter
#[test]
fn test_suffix_only_filter() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
// Configuration: no prefix, suffix=".pdf"
// Pattern should be "*.pdf"
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "*.pdf".to_string(), target_id.clone());
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.pdf");
assert!(targets.contains(&target_id));
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "document.txt");
assert!(!targets.contains(&target_id));
}
/// Test empty pattern (no filter)
#[test]
fn test_no_filter() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
// Configuration: no prefix, no suffix
// Pattern should default to "*" (match all)
rules_map.add_rule_config(&[EventName::ObjectCreatedAll], "".to_string(), target_id.clone());
// All keys should match
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "anything.csv");
assert!(targets.contains(&target_id));
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "uploads/test.txt");
assert!(targets.contains(&target_id));
}
/// Test different event types
#[test]
fn test_different_event_types() {
let mut rules_map = RulesMap::new();
let put_target = TargetID::new("put-target".to_string(), "webhook".to_string());
let delete_target = TargetID::new("delete-target".to_string(), "webhook".to_string());
// Add rules for different event types
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), put_target.clone());
rules_map.add_rule_config(&[EventName::ObjectRemovedDelete], "*.csv".to_string(), delete_target.clone());
// Put event should match put_target
let targets = rules_map.match_rules(EventName::ObjectCreatedPut, "test.csv");
assert!(targets.contains(&put_target));
assert!(!targets.contains(&delete_target));
// Delete event should match delete_target
let targets = rules_map.match_rules(EventName::ObjectRemovedDelete, "test.csv");
assert!(!targets.contains(&put_target));
assert!(targets.contains(&delete_target));
}
/// Test remove_map
#[test]
fn test_remove_map() {
let mut rules_map1 = RulesMap::new();
let mut rules_map2 = RulesMap::new();
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
let target2 = TargetID::new("target2".to_string(), "webhook".to_string());
rules_map1.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
rules_map1.add_rule_config(&[EventName::ObjectCreatedPost], "*.jpg".to_string(), target1.clone());
rules_map2.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target2.clone());
// Remove rules_map2 from rules_map1
rules_map1.remove_map(&rules_map2);
// ObjectCreatedPut rule should still exist with target1 (different from target2)
let targets = rules_map1.match_rules(EventName::ObjectCreatedPut, "test.csv");
assert!(targets.contains(&target1));
assert!(!targets.contains(&target2));
// ObjectCreatedPost rule should still exist
let targets = rules_map1.match_rules(EventName::ObjectCreatedPost, "test.jpg");
assert!(targets.contains(&target1));
}
/// Test remove_map removing same target
#[test]
fn test_remove_map_same_target() {
let mut rules_map1 = RulesMap::new();
let mut rules_map2 = RulesMap::new();
let target1 = TargetID::new("target1".to_string(), "webhook".to_string());
rules_map1.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
rules_map1.add_rule_config(&[EventName::ObjectCreatedPost], "*.jpg".to_string(), target1.clone());
rules_map2.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target1.clone());
// Remove rules_map2 from rules_map1
rules_map1.remove_map(&rules_map2);
// ObjectCreatedPut rule should be removed (same target and pattern)
let targets = rules_map1.match_rules(EventName::ObjectCreatedPut, "test.csv");
assert!(targets.is_empty());
// ObjectCreatedPost rule should still exist
let targets = rules_map1.match_rules(EventName::ObjectCreatedPost, "test.jpg");
assert!(targets.contains(&target1));
}
/// Test contains_target_id
#[test]
fn test_contains_target_id() {
let mut rules_map = RulesMap::new();
let target_id = TargetID::new("test-target".to_string(), "webhook".to_string());
assert!(!rules_map.contains_target_id(&target_id));
rules_map.add_rule_config(&[EventName::ObjectCreatedPut], "*.csv".to_string(), target_id.clone());
assert!(rules_map.contains_target_id(&target_id));
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Pattern matching tests for notification filter rules
//!
//! This module contains tests for the pattern matching logic used in
//! bucket notification filters, specifically testing prefix and suffix
//! filter rules as used in S3 bucket notifications.
use super::pattern;
#[cfg(test)]
mod bug_reproduction_tests {
use super::*;
/// Test case from bug report:
/// - Prefix: "uploads/"
/// - Suffix: ".csv"
/// - Object key: "uploads/test.csv"
#[test]
fn test_uploads_csv_pattern() {
let pattern = pattern::new_pattern(Some("uploads/"), Some(".csv"));
assert_eq!(pattern, "uploads/*.csv");
assert!(pattern::match_simple(&pattern, "uploads/test.csv"));
assert!(pattern::match_simple(&pattern, "uploads/subdir/file.csv"));
assert!(!pattern::match_simple(&pattern, "uploads/test.txt"));
}
/// Test prefix only
#[test]
fn test_prefix_only() {
let pattern = pattern::new_pattern(Some("images/"), None);
assert_eq!(pattern, "images/*");
assert!(pattern::match_simple(&pattern, "images/photo.jpg"));
assert!(pattern::match_simple(&pattern, "images/subdir/photo.png"));
assert!(!pattern::match_simple(&pattern, "documents/photo.jpg"));
}
/// Test suffix only
#[test]
fn test_suffix_only() {
let pattern = pattern::new_pattern(None, Some(".jpg"));
assert_eq!(pattern, "*.jpg");
assert!(pattern::match_simple(&pattern, "photo.jpg"));
assert!(pattern::match_simple(&pattern, "uploads/photo.jpg"));
assert!(pattern::match_simple(&pattern, "a/b/photo.jpg"));
assert!(!pattern::match_simple(&pattern, "photo.png"));
}
/// Test empty pattern (no filter)
#[test]
fn test_empty_pattern() {
let pattern = pattern::new_pattern(None, None);
assert_eq!(pattern, "");
// Empty pattern should not match anything
assert!(!pattern::match_simple(&pattern, "anything"));
}
/// Test complex patterns
#[test]
fn test_complex_patterns() {
// Pattern with both prefix and suffix
let pattern = pattern::new_pattern(Some("data/uploads/"), Some(".csv"));
assert_eq!(pattern, "data/uploads/*.csv");
assert!(pattern::match_simple(&pattern, "data/uploads/test.csv"));
assert!(!pattern::match_simple(&pattern, "data/test.csv"));
// Pattern with prefix containing special characters
let pattern = pattern::new_pattern(Some("special-chars_123/"), Some(".txt"));
assert_eq!(pattern, "special-chars_123/*.txt");
assert!(pattern::match_simple(&pattern, "special-chars_123/file.txt"));
}
/// Test edge cases
#[test]
fn test_edge_cases() {
// Empty prefix and non-empty suffix
let pattern = pattern::new_pattern(Some(""), Some(".csv"));
assert_eq!(pattern, "*.csv");
// Non-empty prefix and empty suffix
let pattern = pattern::new_pattern(Some("uploads/"), Some(""));
assert_eq!(pattern, "uploads/*");
// Pattern ending with star in prefix
let pattern = pattern::new_pattern(Some("uploads*"), Some(".csv"));
assert_eq!(pattern, "uploads*.csv");
// Pattern starting with star in suffix
let pattern = pattern::new_pattern(Some("uploads/"), Some("*csv"));
assert_eq!(pattern, "uploads/*csv");
}
/// Test the exact scenario from bug report
#[test]
fn test_bug_report_exact_scenario() {
// Configuration: prefix="uploads/", suffix=".csv"
let prefix = Some("uploads/");
let suffix = Some(".csv");
let object_key = "uploads/test.csv";
// Generate pattern from filter rules
let pattern = pattern::new_pattern(prefix, suffix);
println!("Generated pattern: '{}'", pattern);
// Verify pattern is correct
assert_eq!(pattern, "uploads/*.csv");
// Test matching
let matches = pattern::match_simple(&pattern, object_key);
println!("Pattern '{}' matches key '{}': {}", pattern, object_key, matches);
assert!(matches, "Pattern '{}' should match object key '{}'", pattern, object_key);
// Test other keys
assert!(pattern::match_simple(&pattern, "uploads/file1.csv"));
assert!(pattern::match_simple(&pattern, "uploads/nested/file.csv"));
assert!(!pattern::match_simple(&pattern, "uploads/file.txt"));
assert!(!pattern::match_simple(&pattern, "files/test.csv"));
}
/// Test with multiple slashes in path
#[test]
fn test_multiple_slashes() {
let pattern = pattern::new_pattern(Some("a/b/c/"), Some(".csv"));
assert_eq!(pattern, "a/b/c/*.csv");
assert!(pattern::match_simple(&pattern, "a/b/c/test.csv"));
assert!(!pattern::match_simple(&pattern, "a/b/test.csv"));
}
/// Test wildcard behavior
#[test]
fn test_wildcard_behavior() {
// Test that * matches any characters including slashes
let pattern = "uploads/*.csv";
assert!(pattern::match_simple(pattern, "uploads/test.csv"));
assert!(pattern::match_simple(pattern, "uploads/subdir/test.csv"));
assert!(pattern::match_simple(pattern, "uploads/a/b/c/test.csv"));
// Test that multiple wildcards work
let pattern = "a*b*c.csv";
assert!(pattern::match_simple(pattern, "a123b456c.csv"));
assert!(pattern::match_simple(pattern, "abc.csv"));
}
/// Test match_all pattern
#[test]
fn test_match_all_pattern() {
// S3 uses "*" to match all objects
let pattern = "*";
assert!(pattern::match_simple(pattern, "anything"));
assert!(pattern::match_simple(pattern, "uploads/test.csv"));
assert!(pattern::match_simple(pattern, ""));
}
}
+101 -13
View File
@@ -83,17 +83,20 @@ impl FilterRule {
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct FilterRuleList {
#[derive(Debug, Serialize, Clone, Default, PartialEq, Eq)]
pub struct S3KeyFilter {
#[serde(rename = "FilterRule", default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<FilterRule>,
pub filter_rule_list: Vec<FilterRule>,
}
impl FilterRuleList {
impl S3KeyFilter {
/// Validate filter rules for duplicates.
/// According to AWS S3 documentation, there can be at most one prefix
/// and one suffix filter rule per queue configuration.
pub fn validate(&self) -> Result<(), ParseConfigError> {
let mut has_prefix = false;
let mut has_suffix = false;
for rule in &self.rules {
for rule in &self.filter_rule_list {
rule.validate()?;
if rule.name == "prefix" {
if has_prefix {
@@ -110,11 +113,19 @@ impl FilterRuleList {
Ok(())
}
/// Check if filter rule list is empty.
pub fn is_empty(&self) -> bool {
self.filter_rule_list.is_empty()
}
/// Generate pattern string from filter rules.
/// This method extracts prefix and suffix values from filter rules
/// and generates a wildcard pattern string for matching object keys.
pub fn pattern(&self) -> String {
let mut prefix_val: Option<&str> = None;
let mut suffix_val: Option<&str> = None;
for rule in &self.rules {
for rule in &self.filter_rule_list {
if rule.name == "prefix" {
prefix_val = Some(&rule.value);
} else if rule.name == "suffix" {
@@ -123,16 +134,92 @@ impl FilterRuleList {
}
pattern::new_pattern(prefix_val, suffix_val)
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
#[derive(Debug, serde::Deserialize)]
struct S3KeyContent {
#[serde(rename = "FilterRule", default)]
filter_rule_list: Vec<FilterRule>,
#[serde(rename = "FilterRuleList", default)]
filter_rule_list_wrapper: Option<S3KeyFilterRuleList>,
}
#[derive(Debug, serde::Deserialize)]
struct S3KeyFilterRuleList {
#[serde(rename = "FilterRule", default)]
filter_rule_list: Vec<FilterRule>,
}
impl S3KeyContent {
/// Get all filter rules from this S3Key content, handling both direct FilterRule
/// and FilterRuleList wrapper structures
fn get_filter_rules(&self) -> Vec<FilterRule> {
// If we have a FilterRuleList wrapper, use that
if let Some(wrapper) = &self.filter_rule_list_wrapper
&& !wrapper.filter_rule_list.is_empty()
{
return wrapper.filter_rule_list.clone();
}
// Otherwise use direct FilterRule list
self.filter_rule_list.clone()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct S3KeyFilter {
#[serde(rename = "FilterRuleList", default, skip_serializing_if = "FilterRuleList::is_empty")]
pub filter_rule_list: FilterRuleList,
/// Custom deserializer for S3KeyFilter to handle Filter element correctly.
/// AWS S3 XML structure: <Filter><S3Key><FilterRule>...</FilterRule></S3Key></Filter>
impl<'de> Deserialize<'de> for S3KeyFilter {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct S3KeyFilterVisitor {
filter_rules: Vec<FilterRule>,
}
impl S3KeyFilterVisitor {
fn new() -> Self {
Self {
filter_rules: Vec::new(),
}
}
}
impl<'de> serde::de::Visitor<'de> for S3KeyFilterVisitor {
type Value = S3KeyFilter;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
formatter,
"an S3Key filter map with an `S3Key` element containing one or more \
`FilterRule` children (e.g. <Filter><S3Key><FilterRule>...</FilterRule></S3Key></Filter>)"
)
}
fn visit_map<V>(mut self, mut map: V) -> Result<Self::Value, V::Error>
where
V: serde::de::MapAccess<'de>,
{
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"S3Key" => {
// Parse S3Key content which contains FilterRule(s)
let s3key_content: S3KeyContent = map.next_value()?;
self.filter_rules = s3key_content.get_filter_rules();
}
_ => {
map.next_value::<serde::de::IgnoredAny>()?;
}
}
}
Ok(S3KeyFilter {
filter_rule_list: self.filter_rules,
})
}
}
deserializer.deserialize_map(S3KeyFilterVisitor::new())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
@@ -163,7 +250,7 @@ impl QueueConfig {
return Err(ParseConfigError::DuplicateEventName(event.to_string()));
}
}
self.filter.filter_rule_list.validate()?;
self.filter.validate()?;
// Validate ARN (similar to Go's Queue.Validate)
// The Go code checks targetList.Exists(q.ARN.TargetID)
@@ -241,6 +328,7 @@ pub struct NotificationConfiguration {
skip_serializing_if = "Vec::is_empty"
)]
pub lambda_list: Vec<LambdaConfigDetail>, // Modify: Use a new structure
#[serde(
rename = "TopicConfiguration", // Tags for each topic configuration item in XML
default,