1use crate::config::Config;
2use crate::date::{iso_to_secs, parse_rfc2822_date};
3use crate::message::FilteredReason;
4use regex::{Regex, RegexBuilder};
5
6const MAX_REGEX_PATTERN_LEN: usize = 512;
8
9const MAX_REGEX_INPUT_LEN: usize = 64 * 1024;
11
12const REGEX_SIZE_LIMIT: usize = 1 << 20; const REGEX_DFA_SIZE_LIMIT: usize = 1 << 20;
15
16fn compile_filter_regex(pattern: &str) -> Option<Regex> {
18 if pattern.len() > MAX_REGEX_PATTERN_LEN {
19 log::warn!("Filter regex pattern exceeds {} bytes; ignoring", MAX_REGEX_PATTERN_LEN);
20 return None;
21 }
22 match RegexBuilder::new(pattern)
23 .size_limit(REGEX_SIZE_LIMIT)
24 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
25 .build()
26 {
27 Ok(r) => Some(r),
28 Err(e) => {
29 log::warn!("Invalid or too-complex filter regex '{}': {}", pattern, e);
30 None
31 },
32 }
33}
34
35fn truncate_for_regex(s: &str) -> &str {
36 if s.len() <= MAX_REGEX_INPUT_LEN {
37 s
38 } else {
39 &s[..s.floor_char_boundary(MAX_REGEX_INPUT_LEN)]
40 }
41}
42
43pub fn check_header_filter(headers: &[(String, String)], filter_list: &[String]) -> Vec<usize> {
44 let mut matches = Vec::new();
45 for (i, pattern) in filter_list.iter().enumerate() {
46 let re = match compile_filter_regex(pattern) {
47 Some(r) => r,
48 None => continue,
49 };
50 for (name, value) in headers {
51 let header_line = format!("{}: {}", name, value);
52 let name_t = truncate_for_regex(name);
53 let value_t = truncate_for_regex(value);
54 let line_t = truncate_for_regex(&header_line);
55 if re.is_match(line_t) || re.is_match(name_t) || re.is_match(value_t) {
56 matches.push(i);
57 break;
58 }
59 }
60 }
61 if matches.is_empty() {
62 matches
63 } else {
64 vec![matches[0]]
65 }
66}
67
68pub fn check_body_filter(body_lines: &[String], filter_list: &[String]) -> Vec<usize> {
69 let mut matches = Vec::new();
70 for (i, pattern) in filter_list.iter().enumerate() {
71 let re = match compile_filter_regex(pattern) {
72 Some(r) => r,
73 None => continue,
74 };
75 for line in body_lines {
76 if re.is_match(truncate_for_regex(line)) {
77 matches.push(i);
78 break;
79 }
80 }
81 }
82 matches
83}
84
85pub fn check_deleted_headers(headers: &[(String, String)], deleted_list: &[String]) -> bool {
86 for (name, value) in headers {
87 if deleted_list.iter().any(|d| d.eq_ignore_ascii_case(name))
88 && value.trim().eq_ignore_ascii_case("yes")
89 {
90 return true;
91 }
92 }
93 false
94}
95
96fn parse_date_flexible(s: &str) -> Option<i64> {
97 parse_rfc2822_date(s).ok().or_else(|| iso_to_secs(s).ok())
98}
99
100pub fn check_expires_headers(headers: &[(String, String)], expires_list: &[String]) -> bool {
101 let now = std::time::SystemTime::now()
102 .duration_since(std::time::UNIX_EPOCH)
103 .unwrap_or_default()
104 .as_secs() as i64;
105 for (name, value) in headers {
106 if expires_list.iter().any(|e| e.eq_ignore_ascii_case(name)) {
107 if let Some(t) = parse_date_flexible(value.trim()) {
108 if t < now {
109 return true;
110 }
111 }
112 }
113 }
114 false
115}
116
117pub fn check_delete_age(date: i64, delete_older: Option<&str>, delete_newer: Option<&str>) -> i32 {
118 let mut result = 0;
119 if let Some(older) = delete_older {
120 match parse_date_flexible(older) {
121 Some(threshold) => {
122 if date > 0 && date < threshold {
123 result |= FilteredReason::FilteredOld as i32;
124 }
125 },
126 None => log::warn!("Could not parse delete_older date: {}", older),
127 }
128 }
129 if let Some(newer) = delete_newer {
130 match parse_date_flexible(newer) {
131 Some(threshold) => {
132 if date > 0 && date > threshold {
133 result |= FilteredReason::FilteredNew as i32;
134 }
135 },
136 None => log::warn!("Could not parse delete_newer date: {}", newer),
137 }
138 }
139 result
140}
141
142pub fn check_delete_msgnum(msgnum: i32, delete_list: &[String]) -> bool {
143 for entry in delete_list {
144 if let Ok(n) = entry.parse::<i32>() {
145 if n == msgnum {
146 return true;
147 }
148 }
149 }
150 false
151}
152
153pub fn require_all_matched(require_results: &[bool]) -> bool {
154 require_results.iter().all(|&r| r)
155}
156
157pub fn apply_filters(
158 msgnum: i32,
159 headers: &[(String, String)],
160 body_lines: &[String],
161 date: i64,
162 config: &Config,
163) -> (i32, Vec<bool>) {
164 let mut is_deleted = 0;
165
166 if !config.filter_out.values.is_empty()
167 && !check_header_filter(headers, &config.filter_out.values).is_empty()
168 {
169 is_deleted |= FilteredReason::FilteredOut as i32;
170 }
171
172 if !config.filter_out_full_body.values.is_empty()
173 && !check_body_filter(body_lines, &config.filter_out_full_body.values).is_empty()
174 {
175 is_deleted |= FilteredReason::FilteredOut as i32;
176 }
177
178 if !config.deleted.values.is_empty() && check_deleted_headers(headers, &config.deleted.values) {
179 is_deleted |= FilteredReason::Delete as i32;
180 }
181
182 if !config.expires.values.is_empty() && check_expires_headers(headers, &config.expires.values) {
183 is_deleted |= FilteredReason::Expire as i32;
184 }
185
186 is_deleted |=
187 check_delete_age(date, config.delete_older.as_deref(), config.delete_newer.as_deref());
188
189 if check_delete_msgnum(msgnum, &config.delete_msgnum.values) {
190 is_deleted |= FilteredReason::Delete as i32;
191 }
192
193 let require_results: Vec<bool> = config
195 .filter_require
196 .values
197 .iter()
198 .map(|pattern| {
199 let re = match compile_filter_regex(pattern) {
200 Some(r) => r,
201 None => return false,
202 };
203 headers.iter().any(|(name, value)| {
204 let header_line = format!("{}: {}", name, value);
205 re.is_match(truncate_for_regex(&header_line))
206 || re.is_match(truncate_for_regex(name))
207 || re.is_match(truncate_for_regex(value))
208 })
209 })
210 .collect();
211
212 let require_body_results: Vec<bool> = config
213 .filter_require_full_body
214 .values
215 .iter()
216 .map(|pattern| {
217 let re = match compile_filter_regex(pattern) {
218 Some(r) => r,
219 None => return false,
220 };
221 body_lines.iter().any(|line| re.is_match(truncate_for_regex(line)))
222 })
223 .collect();
224
225 let all_require = [require_results, require_body_results].concat();
226 (is_deleted, all_require)
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::config::Config;
233
234 #[test]
235 fn test_check_header_filter_match() {
236 let headers = vec![("subject".to_string(), "test message".to_string())];
237 let filters = vec!["test".to_string()];
238 assert!(!check_header_filter(&headers, &filters).is_empty());
239 }
240
241 #[test]
242 fn test_check_header_filter_no_match() {
243 let headers = vec![("subject".to_string(), "hello".to_string())];
244 let filters = vec!["test".to_string()];
245 assert!(check_header_filter(&headers, &filters).is_empty());
246 }
247
248 #[test]
249 fn test_check_body_filter_match() {
250 let lines = vec!["hello world".to_string(), "spam content".to_string()];
251 let filters = vec!["spam".to_string()];
252 assert!(!check_body_filter(&lines, &filters).is_empty());
253 }
254
255 #[test]
256 fn test_check_body_filter_no_match() {
257 let lines = vec!["hello world".to_string()];
258 let filters = vec!["spam".to_string()];
259 assert!(check_body_filter(&lines, &filters).is_empty());
260 }
261
262 #[test]
263 fn test_check_deleted_headers_match() {
264 let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
265 let deleted = vec!["X-Hypermail-Deleted".to_string()];
266 assert!(check_deleted_headers(&headers, &deleted));
267 }
268
269 #[test]
270 fn test_check_deleted_headers_no_value() {
271 let headers = vec![("x-hypermail-deleted".to_string(), "no".to_string())];
272 let deleted = vec!["X-Hypermail-Deleted".to_string()];
273 assert!(!check_deleted_headers(&headers, &deleted));
274 }
275
276 #[test]
277 fn test_check_expires_past() {
278 let headers = vec![("expires".to_string(), "2000-01-01T00:00:00Z".to_string())];
279 let expires = vec!["Expires".to_string()];
280 assert!(check_expires_headers(&headers, &expires));
281 }
282
283 #[test]
284 fn test_check_expires_future() {
285 let headers = vec![("expires".to_string(), "Mon, 01 Jan 2099 00:00:00 +0000".to_string())];
286 let expires = vec!["Expires".to_string()];
287 assert!(!check_expires_headers(&headers, &expires));
288 }
289
290 #[test]
291 fn test_delete_older() {
292 let result = check_delete_age(1000, Some("2000-01-01"), None);
293 assert!(result & FilteredReason::FilteredOld as i32 != 0);
294 }
295
296 #[test]
297 fn test_delete_newer() {
298 let result = check_delete_age(9999999999, None, Some("2000-01-01"));
299 assert!(result & FilteredReason::FilteredNew as i32 != 0);
300 }
301
302 #[test]
303 fn test_check_delete_msgnum_match() {
304 let list = vec!["5".to_string(), "10".to_string()];
305 assert!(check_delete_msgnum(5, &list));
306 assert!(check_delete_msgnum(10, &list));
307 assert!(!check_delete_msgnum(7, &list));
308 }
309
310 #[test]
311 fn test_apply_filters_basic() {
312 let config = Config::default();
313 let headers = vec![("subject".to_string(), "hello".to_string())];
314 let body = vec!["some body".to_string()];
315 let (deleted, _) = apply_filters(1, &headers, &body, 1000, &config);
316 assert_eq!(deleted, 0);
317 }
318
319 #[test]
320 fn test_require_all_matched_true() {
321 assert!(require_all_matched(&[true, true, true]));
322 }
323
324 #[test]
325 fn test_require_all_matched_false() {
326 assert!(!require_all_matched(&[true, false, true]));
327 }
328
329 #[test]
330 fn test_require_all_matched_empty_is_true() {
331 assert!(require_all_matched(&[]));
332 }
333
334 #[test]
335 fn test_apply_filters_filter_out_header_match() {
336 use crate::message::FilteredReason;
337 let mut config = Config::default();
338 config.filter_out.values.push("spam".to_string());
339 let headers = vec![("subject".to_string(), "spam message".to_string())];
340 let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
341 assert_ne!(deleted & FilteredReason::FilteredOut as i32, 0);
342 }
343
344 #[test]
345 fn test_apply_filters_deleted_header() {
346 use crate::message::FilteredReason;
347 let mut config = Config::default();
348 config.deleted.values.push("X-Hypermail-Deleted".to_string());
349 let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
350 let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
351 assert_ne!(deleted & FilteredReason::Delete as i32, 0);
352 }
353
354 #[test]
355 fn test_apply_filters_delete_msgnum() {
356 use crate::message::FilteredReason;
357 let mut config = Config::default();
358 config.delete_msgnum.values.push("42".to_string());
359 let (deleted, _) = apply_filters(42, &[], &[], 1000, &config);
360 assert_ne!(deleted & FilteredReason::Delete as i32, 0);
361 }
362
363 #[test]
364 fn test_check_header_filter_matches_header_name() {
365 let headers = vec![("x-spam-flag".to_string(), "no".to_string())];
366 let filters = vec!["x-spam-flag".to_string()];
367 assert!(!check_header_filter(&headers, &filters).is_empty());
368 }
369
370 #[test]
371 fn test_oversized_regex_pattern_ignored() {
372 let headers = vec![("subject".to_string(), "test".to_string())];
373 let huge = "a".repeat(MAX_REGEX_PATTERN_LEN + 1);
374 let filters = vec![huge];
375 assert!(check_header_filter(&headers, &filters).is_empty());
376 }
377}