1use crate::rule::ast::{RuleCondition, RuleFormula};
4use std::sync::LazyLock;
5use vyre_foundation::ir::{BufferDecl, DataType, Expr, Node, Program};
6pub const WORKGROUP_SIZE: [u32; 3] = [64, 1, 1];
8
9pub const RULE_SET_OP_ID: &str = "vyre-libs::rule::rule_set";
14
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum RuleBuildError {
18 #[error(
20 "RuleCondition::{condition} is not lowerable by the core rule builder for rule {rule_id}. Fix: {fix}"
21 )]
22 UnsupportedCondition {
23 rule_id: u32,
25 condition: &'static str,
27 fix: &'static str,
29 },
30 #[error(
32 "RuleCondition::Opaque extension {extension_id:#010x} is not lowerable by the core rule builder for rule {rule_id}. Fix: use an extension-aware rule builder that maps this extension to concrete IR, or pre-evaluate the opaque condition before building a core rule program."
33 )]
34 OpaqueCondition {
35 rule_id: u32,
37 extension_id: u32,
39 },
40}
41
42#[must_use]
62pub fn build_rule_program(rules: &[(RuleFormula, u32)]) -> Result<Program, RuleBuildError> {
63 let nodes = rule_nodes(rules)?;
64 Ok(Program::wrapped(
65 rule_buffers(),
66 WORKGROUP_SIZE,
67 vec![crate::region::wrap_anonymous(RULE_SET_OP_ID, nodes)],
68 ))
69}
70
71pub fn try_build_rule_program(rules: &[(RuleFormula, u32)]) -> Result<Program, RuleBuildError> {
82 build_rule_program(rules)
83}
84
85#[must_use]
93pub fn rule_buffers() -> Vec<BufferDecl> {
94 static TEMPLATE: LazyLock<Vec<BufferDecl>> = LazyLock::new(|| {
95 vec![
96 BufferDecl::read("rule_ids", 0, DataType::U32),
97 BufferDecl::read("pattern_ids", 1, DataType::U32),
98 BufferDecl::read("rule_bitmaps", 2, DataType::U32),
99 BufferDecl::read("rule_counts", 3, DataType::U32),
100 BufferDecl::read("file_size", 4, DataType::U32),
101 BufferDecl::output("verdicts", 5, DataType::U32),
102 ]
103 });
104 TEMPLATE.clone()
105}
106
107#[must_use]
116pub fn rule_nodes(rules: &[(RuleFormula, u32)]) -> Result<Vec<Node>, RuleBuildError> {
117 rules
118 .iter()
119 .map(|(formula, rule_id)| {
120 Ok(Node::if_then(
121 Expr::lt(Expr::u32(*rule_id), Expr::buf_len("verdicts")),
122 vec![Node::store(
123 "verdicts",
124 Expr::u32(*rule_id),
125 formula_expr(formula, *rule_id)?,
126 )],
127 ))
128 })
129 .collect()
130}
131
132pub fn try_rule_nodes(rules: &[(RuleFormula, u32)]) -> Result<Vec<Node>, RuleBuildError> {
139 rule_nodes(rules)
140}
141
142#[must_use]
150pub fn formula_expr(formula: &RuleFormula, rule_id: u32) -> Result<Expr, RuleBuildError> {
151 match formula {
152 RuleFormula::Condition(condition) => condition_expr(condition, rule_id),
153 RuleFormula::And(left, right) => Ok(Expr::and(
154 formula_expr(left, rule_id)?,
155 formula_expr(right, rule_id)?,
156 )),
157 RuleFormula::Or(left, right) => Ok(Expr::or(
158 formula_expr(left, rule_id)?,
159 formula_expr(right, rule_id)?,
160 )),
161 RuleFormula::Not(formula) => Ok(Expr::not(formula_expr(formula, rule_id)?)),
162 }
163}
164
165pub fn try_formula_expr(formula: &RuleFormula, rule_id: u32) -> Result<Expr, RuleBuildError> {
172 formula_expr(formula, rule_id)
173}
174
175#[must_use]
183pub fn condition_expr(condition: &RuleCondition, rule_id: u32) -> Result<Expr, RuleBuildError> {
184 try_condition_expr(condition, rule_id)
185}
186
187pub fn try_condition_expr(condition: &RuleCondition, rule_id: u32) -> Result<Expr, RuleBuildError> {
196 match condition {
197 RuleCondition::PatternExists { pattern_id } => {
198 Ok(Expr::ne(pattern_state(*pattern_id), Expr::u32(0)))
199 }
200 RuleCondition::PatternCountGt {
201 pattern_id,
202 threshold,
203 } => Ok(Expr::gt(pattern_count(*pattern_id), Expr::u32(*threshold))),
204 RuleCondition::PatternCountGte {
205 pattern_id,
206 threshold,
207 } => Ok(Expr::ge(pattern_count(*pattern_id), Expr::u32(*threshold))),
208 RuleCondition::FileSizeLt(threshold) => Ok(file_size_cmp(Expr::lt, *threshold, true)),
209 RuleCondition::FileSizeLte(threshold) => Ok(file_size_cmp(Expr::le, *threshold, true)),
210 RuleCondition::FileSizeGt(threshold) => Ok(file_size_cmp(Expr::gt, *threshold, false)),
211 RuleCondition::FileSizeGte(threshold) => Ok(file_size_cmp(Expr::ge, *threshold, false)),
212 RuleCondition::FileSizeEq(threshold) => Ok(file_size_cmp(Expr::eq, *threshold, false)),
213 RuleCondition::FileSizeNe(threshold) => Ok(file_size_cmp(Expr::ne, *threshold, true)),
214 RuleCondition::LiteralTrue => Ok(Expr::u32(1)),
215 RuleCondition::LiteralFalse => Ok(Expr::u32(0)),
216 RuleCondition::RegexMatch { .. } => Err(unsupported_rule_condition(
217 rule_id,
218 "RegexMatch",
219 "lower the regex against a concrete buffer in an extension-aware builder, or pre-evaluate the regex condition before calling the core builder.",
220 )),
221 RuleCondition::SubstringMatch { .. } => Err(unsupported_rule_condition(
222 rule_id,
223 "SubstringMatch",
224 "lower the substring predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
225 )),
226 RuleCondition::PrefixMatch { .. } => Err(unsupported_rule_condition(
227 rule_id,
228 "PrefixMatch",
229 "lower the prefix predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
230 )),
231 RuleCondition::SuffixMatch { .. } => Err(unsupported_rule_condition(
232 rule_id,
233 "SuffixMatch",
234 "lower the suffix predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
235 )),
236 RuleCondition::RangeMatch { value, min, max } => {
237 Ok(bool_expr(min <= value && value <= max))
238 }
239 RuleCondition::SetMembership { value, set } => Ok(bool_expr(
240 set.iter()
241 .any(|candidate| candidate.as_ref() == value.as_ref()),
242 )),
243 RuleCondition::FieldInSet { .. } => Err(unsupported_rule_condition(
244 rule_id,
245 "FieldInSet",
246 "FieldInSet requires per-record field lookup; it is supported only by the reference evaluator (`vyre_libs::rule::reference_eval`). Lower against a concrete buffer in an extension-aware builder before calling the core lowering.",
247 )),
248 RuleCondition::Opaque(ext) => Err(RuleBuildError::OpaqueCondition {
249 rule_id,
250 extension_id: ext.extension_id().as_u32(),
251 }),
252 }
253}
254
255fn unsupported_rule_condition(
256 rule_id: u32,
257 condition: &'static str,
258 fix: &'static str,
259) -> RuleBuildError {
260 RuleBuildError::UnsupportedCondition {
261 rule_id,
262 condition,
263 fix,
264 }
265}
266
267fn bool_expr(value: bool) -> Expr {
268 Expr::u32(u32::from(value))
269}
270
271#[must_use]
275pub fn file_size_cmp<F>(cmp_fn: F, threshold: u64, overflow_is_true: bool) -> Expr
276where
277 F: FnOnce(Expr, Expr) -> Expr,
278{
279 match u32::try_from(threshold) {
280 Ok(t) => cmp_fn(Expr::load("file_size", Expr::u32(0)), Expr::u32(t)),
281 Err(_) => {
282 if overflow_is_true {
283 Expr::u32(1)
284 } else {
285 Expr::u32(0)
286 }
287 }
288 }
289}
290
291#[must_use]
294pub fn pattern_state(pattern_id: u32) -> Expr {
295 pattern_buffer_value(pattern_id, "rule_bitmaps")
296}
297
298#[must_use]
301pub fn pattern_count(pattern_id: u32) -> Expr {
302 pattern_buffer_value(pattern_id, "rule_counts")
303}
304
305fn pattern_buffer_value(pattern_id: u32, buffer: &str) -> Expr {
306 Expr::select(
307 Expr::lt(Expr::u32(pattern_id), Expr::buf_len(buffer)),
308 Expr::load(buffer, Expr::u32(pattern_id)),
309 Expr::u32(0),
310 )
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use smallvec::smallvec;
317 use std::any::Any;
318 use std::sync::Arc;
319 use vyre_foundation::extension::RuleConditionExt;
320 use vyre_spec::extension::ExtensionRuleConditionId;
321
322 #[derive(Debug)]
323 struct TestOpaqueCondition;
324
325 impl RuleConditionExt for TestOpaqueCondition {
326 fn extension_id(&self) -> ExtensionRuleConditionId {
327 ExtensionRuleConditionId::from_name("vyre.test.rule.opaque")
328 }
329
330 fn evaluate_opaque(&self, _ctx: &dyn Any) -> bool {
331 true
332 }
333
334 fn stable_fingerprint(&self) -> [u8; 32] {
335 [7; 32]
336 }
337 }
338
339 #[test]
340 fn try_build_rule_program_preserves_supported_conditions() {
341 let formula = RuleFormula::and(
342 RuleFormula::condition(RuleCondition::PatternExists { pattern_id: 3 }),
343 RuleFormula::not(RuleFormula::condition(RuleCondition::FileSizeLt(4096))),
344 );
345
346 let program = try_build_rule_program(&[(formula, 5)]).expect("Fix: supported rule lowers");
347
348 assert!(program.has_buffer("rule_bitmaps"));
349 assert!(program.has_buffer("rule_counts"));
350 assert!(program.has_buffer("file_size"));
351 assert!(program.has_buffer("verdicts"));
352 }
353
354 #[test]
355 fn unsupported_conditions_return_actionable_errors() {
356 let unsupported = vec![
357 RuleCondition::RegexMatch {
358 field: Arc::from("path"),
359 pattern: Arc::from(".*\\.rs"),
360 },
361 RuleCondition::SubstringMatch {
362 haystack: Arc::from("path"),
363 needle: Arc::from("src/"),
364 },
365 RuleCondition::PrefixMatch {
366 value: Arc::from("path"),
367 prefix: Arc::from("src/"),
368 },
369 RuleCondition::SuffixMatch {
370 value: Arc::from("path"),
371 suffix: Arc::from(".rs"),
372 },
373 ];
374
375 for condition in unsupported {
376 let error = try_condition_expr(&condition, 42).expect_err("condition must reject");
377 let message = error.to_string();
378
379 assert!(
380 matches!(
381 error,
382 RuleBuildError::UnsupportedCondition { rule_id: 42, .. }
383 ),
384 "wrong error: {message}"
385 );
386 assert!(message.contains("Fix:"), "missing fix: {message}");
387 assert!(
388 !message.contains("rule.unsupported"),
389 "error must not expose constant-success calls: {message}"
390 );
391 }
392 }
393
394 #[test]
395 fn static_range_and_set_conditions_lower_to_constants() {
396 assert_eq!(
397 try_condition_expr(
398 &RuleCondition::RangeMatch {
399 value: 12,
400 min: 10,
401 max: 20,
402 },
403 7,
404 )
405 .expect("Fix: range condition lowers"),
406 Expr::u32(1)
407 );
408 assert_eq!(
409 try_condition_expr(
410 &RuleCondition::SetMembership {
411 value: Arc::from("critical"),
412 set: smallvec![Arc::from("critical"), Arc::from("high")],
413 },
414 7,
415 )
416 .expect("Fix: set membership condition lowers"),
417 Expr::u32(1)
418 );
419 }
420
421 #[test]
422 fn opaque_condition_returns_construction_error() {
423 let condition = RuleCondition::Opaque(Arc::new(TestOpaqueCondition));
424 let error = try_condition_expr(&condition, 9).expect_err("opaque must reject");
425 let message = error.to_string();
426
427 assert!(
428 matches!(
429 error,
430 RuleBuildError::OpaqueCondition {
431 rule_id: 9,
432 extension_id
433 } if extension_id == ExtensionRuleConditionId::from_name("vyre.test.rule.opaque").as_u32()
434 ),
435 "wrong error: {message}"
436 );
437 assert!(message.contains("extension-aware rule builder"));
438 }
439
440 #[test]
441 fn condition_expr_returns_error_instead_of_panicking_or_constant_success() {
442 let condition = RuleCondition::RegexMatch {
443 field: Arc::from("path"),
444 pattern: Arc::from(".*"),
445 };
446
447 let error = condition_expr(&condition, 1).expect_err("regex condition must reject");
448
449 assert!(
450 matches!(
451 error,
452 RuleBuildError::UnsupportedCondition { rule_id: 1, .. }
453 ),
454 "wrong error: {error}"
455 );
456 }
457}