1#![warn(missing_docs)]
134#![warn(clippy::all)]
135
136mod compiler;
137mod lexer;
138mod loader;
139mod opcode;
140mod parser;
141mod split;
142mod vm;
143
144pub use compiler::{compile, compile_expr, CompileError};
145pub use lexer::{LexError, Lexer, Token};
146pub use loader::{load_filter_file, load_filter_string, LoadError};
147pub use opcode::Opcode;
148pub use parser::{parse, Expr, ParseError, Parser, ParserConfig};
149pub use split::{extract_header_value, PayloadParts, MAX_PARTS};
150pub use vm::{reset_rand_counter, CompiledFilter};
151
152#[cfg(test)]
153mod integration_tests {
154 use bytes::Bytes;
155
156 use super::*;
157
158 fn log_config() -> ParserConfig {
160 let mut config = ParserConfig::default();
161 config.add_field("LEVEL", 0);
162 config.add_field("CODE", 1);
163 config.add_field("METHOD", 2);
164 config.add_field("PATH", 3);
165 config.add_field("HEADERS", 4);
166 config.add_field("BODY", 5);
167 config
168 }
169
170 fn make_record(fields: &[&str]) -> Bytes {
172 Bytes::from(fields.join(";;;"))
173 }
174
175 fn make_full_record(overrides: &[(usize, &str)]) -> Bytes {
177 let mut fields = vec![""; 6];
178 for (idx, value) in overrides {
179 fields[*idx] = value;
180 }
181 make_record(&fields)
182 }
183
184 #[test]
185 fn test_field_equality_and_headers() {
186 let config = log_config();
187 let filter = compile(
188 r#"
189 CODE == "500"
190 AND METHOD == "POST"
191 AND HEADERS.header("Content-Type") iequals "application/json"
192 "#,
193 &config,
194 )
195 .unwrap();
196
197 let record = make_full_record(&[
199 (1, "500"),
200 (2, "POST"),
201 (4, "Content-Type: application/json\r\nHost: example.com\r\n"),
202 ]);
203 assert!(filter.evaluate(record), "Should match all three clauses");
204
205 let record = make_full_record(&[
207 (1, "500"),
208 (2, "POST"),
209 (4, "Content-Type: APPLICATION/JSON\r\n"),
210 ]);
211 assert!(filter.evaluate(record), "Should match case-insensitive");
212
213 let record = make_full_record(&[
215 (1, "200"),
216 (2, "POST"),
217 (4, "Content-Type: application/json\r\n"),
218 ]);
219 assert!(!filter.evaluate(record), "Should not match wrong code");
220
221 let record = make_full_record(&[
223 (1, "500"),
224 (2, "GET"),
225 (4, "Content-Type: application/json\r\n"),
226 ]);
227 assert!(!filter.evaluate(record), "Should not match wrong method");
228
229 let record = make_full_record(&[(1, "500"), (2, "POST"), (4, "Host: example.com\r\n")]);
231 assert!(!filter.evaluate(record), "Should not match missing header");
232 }
233
234 #[test]
235 fn test_url_pattern_matching() {
236 let config = log_config();
237 let filter = compile(
238 r#"
239 LEVEL in {"error", "warn", "fatal"}
240 AND PATH matches "(?i).*/(?:admin|internal)/.*"
241 "#,
242 &config,
243 )
244 .unwrap();
245
246 for level in ["error", "warn", "fatal"] {
247 let record = make_full_record(&[(0, level), (3, "GET /api/admin/users HTTP/1.1")]);
248 assert!(
249 filter.evaluate(record),
250 "Should match level {} with admin URL",
251 level
252 );
253 }
254
255 let record = make_full_record(&[(0, "warn"), (3, "GET /internal/status HTTP/1.1")]);
256 assert!(filter.evaluate(record), "Should match internal URL");
257
258 let record = make_full_record(&[(0, "debug"), (3, "GET /admin/users HTTP/1.1")]);
260 assert!(!filter.evaluate(record), "Should not match debug level");
261
262 let record = make_full_record(&[(0, "error"), (3, "GET /api/users HTTP/1.1")]);
264 assert!(!filter.evaluate(record), "Should not match public URL");
265 }
266
267 #[test]
268 fn test_combined_or() {
269 let config = log_config();
270 let filter = compile(
271 r#"
272 (
273 CODE == "500"
274 AND METHOD == "POST"
275 AND HEADERS.header("Content-Type") iequals "application/json"
276 )
277 OR
278 (
279 LEVEL in {"error", "warn", "fatal"}
280 AND PATH matches "(?i).*/admin/.*"
281 )
282 "#,
283 &config,
284 )
285 .unwrap();
286
287 let record = make_full_record(&[
289 (1, "500"),
290 (2, "POST"),
291 (4, "Content-Type: application/json\r\n"),
292 ]);
293 assert!(filter.evaluate(record), "Should match first branch");
294
295 let record = make_full_record(&[(0, "error"), (3, "POST /api/admin/submit HTTP/1.1")]);
297 assert!(filter.evaluate(record), "Should match second branch");
298
299 let record = make_full_record(&[(0, "info"), (3, "GET /api/users HTTP/1.1")]);
301 assert!(!filter.evaluate(record), "Should match neither branch");
302 }
303
304 #[test]
305 fn test_rand_sampling() {
306 vm::reset_rand_counter();
307
308 let config = log_config();
309 let filter = compile(r#"LEVEL == "error" AND rand(10)"#, &config).unwrap();
310
311 let record = make_record(&["error", "500", "GET"]);
312 let matches: usize = (0..100)
313 .filter(|_| filter.evaluate(record.clone()))
314 .count();
315
316 assert!(
317 matches == 10,
318 "Expected exactly 10 matches with deterministic counter, got {}",
319 matches
320 );
321 }
322
323 #[test]
324 fn test_empty_checks() {
325 let config = log_config();
326
327 let filter = compile("BODY is_empty", &config).unwrap();
328 assert!(filter.evaluate(make_record(&["error", "500", "GET", "/", "", ""])));
329 assert!(!filter.evaluate(make_record(&[
330 "error",
331 "500",
332 "GET",
333 "/",
334 "",
335 "some body"
336 ])));
337
338 let filter = compile("BODY not_empty", &config).unwrap();
339 assert!(!filter.evaluate(make_record(&["error", "500", "GET", "/", "", ""])));
340 assert!(filter.evaluate(make_record(&[
341 "error",
342 "500",
343 "GET",
344 "/",
345 "",
346 "some body"
347 ])));
348 }
349
350 #[test]
351 fn test_case_insensitive_contains() {
352 let config = log_config();
353 let filter = compile(r#"PATH icontains "ADMIN""#, &config).unwrap();
354
355 assert!(filter.evaluate(make_full_record(&[(3, "GET /admin/users HTTP/1.1")])));
356 assert!(filter.evaluate(make_full_record(&[(3, "GET /ADMIN/users HTTP/1.1")])));
357 assert!(filter.evaluate(make_full_record(&[(3, "GET /Admin/users HTTP/1.1")])));
358 assert!(!filter.evaluate(make_full_record(&[(3, "GET /api/users HTTP/1.1")])));
359 }
360
361 #[test]
362 fn test_filter_stats() {
363 let config = log_config();
364 let filter = compile(
365 r#"LEVEL in {"error", "warn"} AND payload matches "timeout""#,
366 &config,
367 )
368 .unwrap();
369
370 assert_eq!(filter.string_count(), 2); assert_eq!(filter.regex_count(), 1); assert!(filter.bytecode_len() > 0);
373 assert_eq!(filter.delimiter(), b";;;");
374 }
375}