1use crate::context::{EvaluationContext, FieldValue};
4use crate::decision::{ActionResult, Decision};
5use crate::error::EngineError;
6use crate::ir::ActionInstruction;
7use crue_dsl::compiler::{Bytecode, Constant, Opcode};
8
9#[derive(Debug, Clone, PartialEq)]
10enum VmValue {
11 Bool(bool),
12 Number(i64),
13 String(String),
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Instruction {
19 LoadField(u16),
20 LoadConst(u32),
21 LoadTrue,
22 LoadFalse,
23 Gt,
24 Lt,
25 Gte,
26 Lte,
27 Eq,
28 Neq,
29 And,
30 Or,
31 Not,
32 JumpIfFalse(usize),
33 Jump(usize),
34 Ret,
35 EmitDecision(Decision),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum VmExit {
41 Bool(bool),
42 Decision(Decision),
43}
44
45pub struct BytecodeVm;
46pub struct ActionVm;
47
48impl BytecodeVm {
49 pub fn eval(bytecode: &Bytecode, ctx: &EvaluationContext) -> Result<bool, EngineError> {
51 let program = Self::decode(bytecode)?;
52 match Self::eval_program(&program, bytecode, ctx)? {
53 VmExit::Bool(v) => Ok(v),
54 VmExit::Decision(_) => Err(EngineError::EvaluationError(
55 "VM emitted decision in boolean eval path".to_string(),
56 )),
57 }
58 }
59
60 pub fn decode(bytecode: &Bytecode) -> Result<Vec<Instruction>, EngineError> {
62 let mut pc = 0usize;
63 let code = &bytecode.instructions;
64 let mut program = Vec::new();
65
66 while pc < code.len() {
67 let op = decode_opcode(code[pc])?;
68 pc += 1;
69 match op {
70 Opcode::LoadField => {
71 program.push(Instruction::LoadField(read_u16(code, &mut pc)?));
72 }
73 Opcode::LoadConst => {
74 program.push(Instruction::LoadConst(read_u32(code, &mut pc)?));
75 }
76 Opcode::LoadTrue => program.push(Instruction::LoadTrue),
77 Opcode::LoadFalse => program.push(Instruction::LoadFalse),
78 Opcode::Gt => program.push(Instruction::Gt),
79 Opcode::Lt => program.push(Instruction::Lt),
80 Opcode::Gte => program.push(Instruction::Gte),
81 Opcode::Lte => program.push(Instruction::Lte),
82 Opcode::Eq => program.push(Instruction::Eq),
83 Opcode::Neq => program.push(Instruction::Neq),
84 Opcode::And => program.push(Instruction::And),
85 Opcode::Or => program.push(Instruction::Or),
86 Opcode::Not => program.push(Instruction::Not),
87 Opcode::Ret => program.push(Instruction::Ret),
88 Opcode::Jmp | Opcode::JmpF => {
89 return Err(EngineError::EvaluationError(
90 "Raw jump opcodes not supported in decoded VM yet".to_string(),
91 ));
92 }
93 }
94 }
95
96 Ok(program)
97 }
98
99 pub fn eval_program(
101 program: &[Instruction],
102 bytecode: &Bytecode,
103 ctx: &EvaluationContext,
104 ) -> Result<VmExit, EngineError> {
105 let mut pc = 0usize;
106 let mut stack: Vec<VmValue> = Vec::new();
107
108 while pc < program.len() {
109 match &program[pc] {
110 Instruction::LoadField(idx) => {
111 let field = bytecode.fields.get(*idx as usize).ok_or_else(|| {
112 EngineError::EvaluationError("Invalid field index".to_string())
113 })?;
114 let value = ctx
115 .get_field(field)
116 .ok_or_else(|| EngineError::FieldNotFound(field.clone()))?;
117 stack.push(field_to_vm(value)?);
118 pc += 1;
119 }
120 Instruction::LoadConst(idx) => {
121 let c = bytecode.constants.get(*idx as usize).ok_or_else(|| {
122 EngineError::EvaluationError("Invalid constant index".to_string())
123 })?;
124 stack.push(constant_to_vm(c));
125 pc += 1;
126 }
127 Instruction::LoadTrue => {
128 stack.push(VmValue::Bool(true));
129 pc += 1;
130 }
131 Instruction::LoadFalse => {
132 stack.push(VmValue::Bool(false));
133 pc += 1;
134 }
135 Instruction::Gt => {
136 binary_compare(&mut stack, |a, b| a > b)?;
137 pc += 1;
138 }
139 Instruction::Lt => {
140 binary_compare(&mut stack, |a, b| a < b)?;
141 pc += 1;
142 }
143 Instruction::Gte => {
144 binary_compare(&mut stack, |a, b| a >= b)?;
145 pc += 1;
146 }
147 Instruction::Lte => {
148 binary_compare(&mut stack, |a, b| a <= b)?;
149 pc += 1;
150 }
151 Instruction::Eq => {
152 binary_eq(&mut stack, true)?;
153 pc += 1;
154 }
155 Instruction::Neq => {
156 binary_eq(&mut stack, false)?;
157 pc += 1;
158 }
159 Instruction::And => {
160 binary_bool(&mut stack, |a, b| a && b)?;
161 pc += 1;
162 }
163 Instruction::Or => {
164 binary_bool(&mut stack, |a, b| a || b)?;
165 pc += 1;
166 }
167 Instruction::Not => {
168 let v = pop_bool(&mut stack)?;
169 stack.push(VmValue::Bool(!v));
170 pc += 1;
171 }
172 Instruction::JumpIfFalse(target) => {
173 let cond = pop_bool(&mut stack)?;
174 if !cond {
175 ensure_target(*target, program.len())?;
176 pc = *target;
177 } else {
178 pc += 1;
179 }
180 }
181 Instruction::Jump(target) => {
182 ensure_target(*target, program.len())?;
183 pc = *target;
184 }
185 Instruction::Ret => {
186 return Ok(VmExit::Bool(pop_bool(&mut stack)?));
187 }
188 Instruction::EmitDecision(decision) => {
189 return Ok(VmExit::Decision(*decision));
190 }
191 }
192 }
193
194 Err(EngineError::EvaluationError(
195 "VM program terminated without RET/EmitDecision".to_string(),
196 ))
197 }
198
199 pub fn eval_decision(
201 bytecode: &Bytecode,
202 ctx: &EvaluationContext,
203 on_true: Decision,
204 on_false: Decision,
205 ) -> Result<Decision, EngineError> {
206 let mut program = Self::decode(bytecode)?;
207 if !matches!(program.last(), Some(Instruction::Ret)) {
208 return Err(EngineError::EvaluationError(
209 "Bytecode terminated without RET".to_string(),
210 ));
211 }
212 program.pop();
213
214 let false_target = program.len() + 2;
215 program.push(Instruction::JumpIfFalse(false_target));
216 program.push(Instruction::EmitDecision(on_true));
217 program.push(Instruction::EmitDecision(on_false));
218
219 match Self::eval_program(&program, bytecode, ctx)? {
220 VmExit::Decision(d) => Ok(d),
221 VmExit::Bool(_) => Err(EngineError::EvaluationError(
222 "VM returned bool in decision eval path".to_string(),
223 )),
224 }
225 }
226
227 pub fn build_match_program(
230 bytecode: &Bytecode,
231 on_match: Decision,
232 ) -> Result<Vec<Instruction>, EngineError> {
233 let mut program = Self::decode(bytecode)?;
234 if !matches!(program.last(), Some(Instruction::Ret)) {
235 return Err(EngineError::EvaluationError(
236 "Bytecode terminated without RET".to_string(),
237 ));
238 }
239 program.pop();
240
241 let false_target = program.len() + 2;
245 program.push(Instruction::JumpIfFalse(false_target));
246 program.push(Instruction::EmitDecision(on_match));
247 program.push(Instruction::LoadFalse);
248 program.push(Instruction::Ret);
249 Ok(program)
250 }
251
252 pub fn eval_match_program(
256 program: &[Instruction],
257 bytecode: &Bytecode,
258 ctx: &EvaluationContext,
259 ) -> Result<Option<Decision>, EngineError> {
260 match Self::eval_program(program, bytecode, ctx)? {
261 VmExit::Decision(d) => Ok(Some(d)),
262 VmExit::Bool(false) => Ok(None),
263 VmExit::Bool(true) => Err(EngineError::EvaluationError(
264 "VM match program returned unexpected true boolean".to_string(),
265 )),
266 }
267 }
268}
269
270impl ActionVm {
271 pub fn execute(program: &[ActionInstruction]) -> Result<ActionResult, EngineError> {
273 let mut decision = Decision::Allow;
274 let mut error_code: Option<String> = None;
275 let mut message: Option<String> = None;
276 let mut approval_timeout: Option<u32> = None;
277 let mut alert_soc = false;
278
279 for insn in program {
280 match insn {
281 ActionInstruction::SetDecision(d) => decision = *d,
282 ActionInstruction::SetErrorCode(code) => error_code = Some(code.clone()),
283 ActionInstruction::SetMessage(msg) => message = Some(msg.clone()),
284 ActionInstruction::SetApprovalTimeout(timeout) => approval_timeout = Some(*timeout),
285 ActionInstruction::SetAlertSoc(v) => alert_soc = *v,
286 ActionInstruction::Halt => break,
287 }
288 }
289
290 let final_message = match decision {
291 Decision::ApprovalRequired => {
292 if let Some(m) = message {
293 Some(m)
294 } else {
295 Some(format!(
296 "Approval required within {} minutes",
297 approval_timeout.unwrap_or(30)
298 ))
299 }
300 }
301 _ => message,
302 };
303
304 Ok(ActionResult {
305 decision,
306 error_code,
307 message: final_message,
308 alert_soc,
309 })
310 }
311}
312
313fn ensure_target(target: usize, len: usize) -> Result<(), EngineError> {
314 if target >= len {
315 return Err(EngineError::EvaluationError(format!(
316 "Invalid jump target {} (program len {})",
317 target, len
318 )));
319 }
320 Ok(())
321}
322
323fn decode_opcode(byte: u8) -> Result<Opcode, EngineError> {
324 let op = match byte {
325 0x01 => Opcode::LoadField,
326 0x02 => Opcode::LoadConst,
327 0x03 => Opcode::LoadTrue,
328 0x04 => Opcode::LoadFalse,
329 0x10 => Opcode::Gt,
330 0x11 => Opcode::Lt,
331 0x12 => Opcode::Gte,
332 0x13 => Opcode::Lte,
333 0x14 => Opcode::Eq,
334 0x15 => Opcode::Neq,
335 0x20 => Opcode::And,
336 0x21 => Opcode::Or,
337 0x22 => Opcode::Not,
338 0x30 => Opcode::JmpF,
339 0x31 => Opcode::Jmp,
340 0xFF => Opcode::Ret,
341 _ => {
342 return Err(EngineError::EvaluationError(format!(
343 "Unknown opcode 0x{byte:02x}"
344 )))
345 }
346 };
347 Ok(op)
348}
349
350fn read_u16(code: &[u8], pc: &mut usize) -> Result<u16, EngineError> {
351 if *pc + 2 > code.len() {
352 return Err(EngineError::EvaluationError(
353 "Truncated u16 operand".to_string(),
354 ));
355 }
356 let v = u16::from_be_bytes([code[*pc], code[*pc + 1]]);
357 *pc += 2;
358 Ok(v)
359}
360
361fn read_u32(code: &[u8], pc: &mut usize) -> Result<u32, EngineError> {
362 if *pc + 4 > code.len() {
363 return Err(EngineError::EvaluationError(
364 "Truncated u32 operand".to_string(),
365 ));
366 }
367 let v = u32::from_be_bytes([code[*pc], code[*pc + 1], code[*pc + 2], code[*pc + 3]]);
368 *pc += 4;
369 Ok(v)
370}
371
372fn constant_to_vm(c: &Constant) -> VmValue {
373 match c {
374 Constant::Number(n) => VmValue::Number(*n),
375 Constant::String(s) => VmValue::String(s.clone()),
376 Constant::Boolean(b) => VmValue::Bool(*b),
377 }
378}
379
380fn field_to_vm(v: &FieldValue) -> Result<VmValue, EngineError> {
381 match v {
382 FieldValue::Number(n) => Ok(VmValue::Number(*n)),
383 FieldValue::String(s) => Ok(VmValue::String(s.clone())),
384 FieldValue::Boolean(b) => Ok(VmValue::Bool(*b)),
385 FieldValue::Float(_) => Err(EngineError::TypeMismatch(
386 "float field unsupported in VM".into(),
387 )),
388 }
389}
390
391fn pop(stack: &mut Vec<VmValue>) -> Result<VmValue, EngineError> {
392 stack
393 .pop()
394 .ok_or_else(|| EngineError::EvaluationError("VM stack underflow".to_string()))
395}
396
397fn pop_bool(stack: &mut Vec<VmValue>) -> Result<bool, EngineError> {
398 match pop(stack)? {
399 VmValue::Bool(v) => Ok(v),
400 _ => Err(EngineError::TypeMismatch("Expected bool".to_string())),
401 }
402}
403
404fn pop_number(stack: &mut Vec<VmValue>) -> Result<i64, EngineError> {
405 match pop(stack)? {
406 VmValue::Number(v) => Ok(v),
407 _ => Err(EngineError::TypeMismatch("Expected number".to_string())),
408 }
409}
410
411fn binary_compare(
412 stack: &mut Vec<VmValue>,
413 cmp: impl Fn(i64, i64) -> bool,
414) -> Result<(), EngineError> {
415 let right = pop_number(stack)?;
416 let left = pop_number(stack)?;
417 stack.push(VmValue::Bool(cmp(left, right)));
418 Ok(())
419}
420
421fn binary_bool(
422 stack: &mut Vec<VmValue>,
423 op: impl Fn(bool, bool) -> bool,
424) -> Result<(), EngineError> {
425 let right = pop_bool(stack)?;
426 let left = pop_bool(stack)?;
427 stack.push(VmValue::Bool(op(left, right)));
428 Ok(())
429}
430
431fn binary_eq(stack: &mut Vec<VmValue>, eq: bool) -> Result<(), EngineError> {
432 let right = pop(stack)?;
433 let left = pop(stack)?;
434 let result = match (left, right) {
435 (VmValue::Bool(a), VmValue::Bool(b)) => a == b,
436 (VmValue::Number(a), VmValue::Number(b)) => a == b,
437 (VmValue::String(a), VmValue::String(b)) => a == b,
438 _ => {
439 return Err(EngineError::TypeMismatch(
440 "Incompatible equality operands".to_string(),
441 ))
442 }
443 };
444 stack.push(VmValue::Bool(if eq { result } else { !result }));
445 Ok(())
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::ir::ActionInstruction;
452 use crate::EvaluationRequest;
453 use crue_dsl::ast::{ActionNode, Expression, MetadataNode, RuleAst, Value};
454
455 #[test]
456 fn test_vm_eval_compiled_rule() {
457 let src = r#"
458RULE CRUE_001 VERSION 1.0
459WHEN
460 agent.requests_last_hour >= 50
461THEN
462 BLOCK WITH CODE "VOLUME_EXCEEDED"
463"#;
464 let ast = crue_dsl::parser::parse(src).unwrap();
465 let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
466
467 let req = EvaluationRequest {
468 request_id: "req".into(),
469 agent_id: "a".into(),
470 agent_org: "o".into(),
471 agent_level: "standard".into(),
472 mission_id: None,
473 mission_type: None,
474 query_type: None,
475 justification: Some("demo justification".into()),
476 export_format: None,
477 result_limit: Some(1),
478 requests_last_hour: 60,
479 requests_last_24h: 100,
480 results_last_query: 1,
481 account_department: None,
482 allowed_departments: vec![],
483 request_hour: 10,
484 is_within_mission_hours: true,
485 };
486 let ctx = EvaluationContext::from_request(&req);
487 assert!(BytecodeVm::eval(&bytecode, &ctx).unwrap());
488 }
489
490 #[test]
491 fn test_vm_eval_decision_emits_decision() {
492 let src = r#"
493RULE CRUE_001 VERSION 1.0
494WHEN
495 agent.requests_last_hour >= 50
496THEN
497 BLOCK WITH CODE "VOLUME_EXCEEDED"
498"#;
499 let ast = crue_dsl::parser::parse(src).unwrap();
500 let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
501 let mut req = EvaluationRequest {
502 request_id: "req".into(),
503 agent_id: "a".into(),
504 agent_org: "o".into(),
505 agent_level: "standard".into(),
506 mission_id: None,
507 mission_type: None,
508 query_type: None,
509 justification: Some("demo justification".into()),
510 export_format: None,
511 result_limit: Some(1),
512 requests_last_hour: 60,
513 requests_last_24h: 100,
514 results_last_query: 1,
515 account_department: None,
516 allowed_departments: vec![],
517 request_hour: 10,
518 is_within_mission_hours: true,
519 };
520 let ctx = EvaluationContext::from_request(&req);
521 assert_eq!(
522 BytecodeVm::eval_decision(&bytecode, &ctx, Decision::Block, Decision::Allow).unwrap(),
523 Decision::Block
524 );
525
526 req.requests_last_hour = 1;
527 let ctx2 = EvaluationContext::from_request(&req);
528 assert_eq!(
529 BytecodeVm::eval_decision(&bytecode, &ctx2, Decision::Block, Decision::Allow).unwrap(),
530 Decision::Allow
531 );
532 }
533
534 #[test]
535 fn test_vm_explicit_jump_and_emit_program() {
536 let bytecode = Bytecode {
537 instructions: vec![],
538 constants: vec![],
539 fields: vec![],
540 action_instructions: vec![],
541 };
542 let req = EvaluationRequest {
543 request_id: "req".into(),
544 agent_id: "a".into(),
545 agent_org: "o".into(),
546 agent_level: "standard".into(),
547 mission_id: None,
548 mission_type: None,
549 query_type: None,
550 justification: None,
551 export_format: None,
552 result_limit: None,
553 requests_last_hour: 0,
554 requests_last_24h: 0,
555 results_last_query: 0,
556 account_department: None,
557 allowed_departments: vec![],
558 request_hour: 0,
559 is_within_mission_hours: true,
560 };
561 let ctx = EvaluationContext::from_request(&req);
562 let program = vec![
563 Instruction::LoadFalse,
564 Instruction::JumpIfFalse(3),
565 Instruction::EmitDecision(Decision::Block),
566 Instruction::EmitDecision(Decision::Allow),
567 ];
568 assert_eq!(
569 BytecodeVm::eval_program(&program, &bytecode, &ctx).unwrap(),
570 VmExit::Decision(Decision::Allow)
571 );
572 }
573
574 #[test]
575 fn test_action_vm_exec_block_with_soc_alert() {
576 let program = vec![
577 ActionInstruction::SetDecision(Decision::Block),
578 ActionInstruction::SetErrorCode("VOLUME_EXCEEDED".into()),
579 ActionInstruction::SetMessage("Demo policy matched".into()),
580 ActionInstruction::SetAlertSoc(true),
581 ActionInstruction::Halt,
582 ];
583 let result = ActionVm::execute(&program).unwrap();
584 assert_eq!(result.decision, Decision::Block);
585 assert_eq!(result.error_code.as_deref(), Some("VOLUME_EXCEEDED"));
586 assert_eq!(result.message.as_deref(), Some("Demo policy matched"));
587 assert!(result.alert_soc);
588 }
589
590 #[test]
591 fn test_action_vm_exec_approval_default_message() {
592 let program = vec![
593 ActionInstruction::SetDecision(Decision::ApprovalRequired),
594 ActionInstruction::SetErrorCode("APPROVAL_REQUIRED".into()),
595 ActionInstruction::SetApprovalTimeout(15),
596 ActionInstruction::Halt,
597 ];
598 let result = ActionVm::execute(&program).unwrap();
599 assert_eq!(result.decision, Decision::ApprovalRequired);
600 assert_eq!(
601 result.message.as_deref(),
602 Some("Approval required within 15 minutes")
603 );
604 }
605
606 #[test]
607 fn test_vm_eval_in_operator() {
608 let ast = RuleAst {
609 id: "CRUE_IN_VM".to_string(),
610 version: "1.0.0".to_string(),
611 signed: false,
612 when_clause: Expression::In(
613 Box::new(Expression::field("request.export_format")),
614 vec![
615 Value::String("PDF".to_string()),
616 Value::String("CSV".to_string()),
617 ],
618 ),
619 then_clause: vec![ActionNode::Log],
620 metadata: MetadataNode {
621 name: "IN".to_string(),
622 description: "IN".to_string(),
623 severity: "LOW".to_string(),
624 category: "TEST".to_string(),
625 author: "system".to_string(),
626 created_at: "2026-01-01".to_string(),
627 validated_by: None,
628 },
629 };
630 let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
631
632 let mut req = EvaluationRequest {
633 request_id: "req".into(),
634 agent_id: "a".into(),
635 agent_org: "o".into(),
636 agent_level: "standard".into(),
637 mission_id: None,
638 mission_type: None,
639 query_type: None,
640 justification: None,
641 export_format: Some("PDF".into()),
642 result_limit: None,
643 requests_last_hour: 0,
644 requests_last_24h: 0,
645 results_last_query: 0,
646 account_department: None,
647 allowed_departments: vec![],
648 request_hour: 12,
649 is_within_mission_hours: true,
650 };
651 let ctx = EvaluationContext::from_request(&req);
652 assert!(BytecodeVm::eval(&bytecode, &ctx).unwrap());
653
654 req.export_format = Some("XML".into());
655 let ctx = EvaluationContext::from_request(&req);
656 assert!(!BytecodeVm::eval(&bytecode, &ctx).unwrap());
657 }
658
659 #[test]
660 fn test_vm_eval_between_operator() {
661 let ast = RuleAst {
662 id: "CRUE_BETWEEN_VM".to_string(),
663 version: "1.0.0".to_string(),
664 signed: false,
665 when_clause: Expression::Between(
666 Box::new(Expression::field("context.request_hour")),
667 Box::new(Expression::number(8)),
668 Box::new(Expression::number(18)),
669 ),
670 then_clause: vec![ActionNode::Log],
671 metadata: MetadataNode {
672 name: "BETWEEN".to_string(),
673 description: "BETWEEN".to_string(),
674 severity: "LOW".to_string(),
675 category: "TEST".to_string(),
676 author: "system".to_string(),
677 created_at: "2026-01-01".to_string(),
678 validated_by: None,
679 },
680 };
681 let bytecode = crue_dsl::compiler::Compiler::compile(&ast).unwrap();
682
683 let mut req = EvaluationRequest {
684 request_id: "req".into(),
685 agent_id: "a".into(),
686 agent_org: "o".into(),
687 agent_level: "standard".into(),
688 mission_id: None,
689 mission_type: None,
690 query_type: None,
691 justification: None,
692 export_format: None,
693 result_limit: None,
694 requests_last_hour: 0,
695 requests_last_24h: 0,
696 results_last_query: 0,
697 account_department: None,
698 allowed_departments: vec![],
699 request_hour: 9,
700 is_within_mission_hours: true,
701 };
702 let ctx = EvaluationContext::from_request(&req);
703 assert!(BytecodeVm::eval(&bytecode, &ctx).unwrap());
704
705 req.request_hour = 22;
706 let ctx = EvaluationContext::from_request(&req);
707 assert!(!BytecodeVm::eval(&bytecode, &ctx).unwrap());
708 }
709}