From 92477709cc5d62b321f3f9b10ac28781847ba60d Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi Date: Tue, 31 Mar 2026 16:17:39 -0500 Subject: [PATCH] feat(rvm): add context/metadata support, new instructions, and serialization v6 Extend the RVM infrastructure with evaluation context, program metadata, and several new instructions needed by language frontends beyond Rego. VM & instructions: - Add LoadContext / LoadMetadata instructions for accessing evaluation context and program metadata from within RVM programs - Add ArrayPushDefined (skip-undefined variant of ArrayPush) for wildcard alias collection where absent properties should be excluded - Add ReturnUndefinedIfNotTrue for early-return semantics without triggering VM assertion failures - Add CoalesceUndefinedToNull for languages where missing fields are null rather than undefined - Support IterationState::Single in loops and comprehensions for iterating over scalar values alongside collections Program metadata: - Extend ProgramMetadata with language identifier, typed annotations (BTreeMap), and to_value() conversion - Add MetadataValue enum (String, Bool, Integer, Float, Array, Object) with full serde support - Add has_host_await flag to Program with recompute_host_await_presence() Serialization: - Bump binary format version to 6 - Add JSON serialization for ProgramMetadata including annotations Compiler: - Track has_host_await during compilation - Minor import cleanups Test infrastructure: - Extend VM test harness with context, metadata_language, and metadata_annotations fields - Add instruction parser support for new instructions - Add assembly listing support (readable, tabular, compact formats) - Add 3 new YAML test suites: load_context_metadata, coalesce_undefined_to_null, return_undefined_if_not_true --- src/rvm/instructions/display.rs | 11 + src/rvm/instructions/mod.rs | 39 ++ src/rvm/program/listing.rs | 43 ++ src/rvm/tests/instruction_parser.rs | 47 +++ src/rvm/tests/vm.rs | 34 ++ src/rvm/vm/comprehension.rs | 6 +- src/rvm/vm/context.rs | 12 + src/rvm/vm/dispatch.rs | 50 +++ src/rvm/vm/loops.rs | 66 ++- src/rvm/vm/machine.rs | 44 +- tests/rvm/vm/suites/array_push_defined.yaml | 92 +++++ .../vm/suites/coalesce_undefined_to_null.yaml | 194 +++++++++ .../rvm/vm/suites/load_context_metadata.yaml | 379 ++++++++++++++++++ .../suites/return_undefined_if_not_true.yaml | 209 ++++++++++ tests/rvm/vm/suites/type_errors.yaml | 2 +- 15 files changed, 1201 insertions(+), 27 deletions(-) create mode 100644 tests/rvm/vm/suites/array_push_defined.yaml create mode 100644 tests/rvm/vm/suites/coalesce_undefined_to_null.yaml create mode 100644 tests/rvm/vm/suites/load_context_metadata.yaml create mode 100644 tests/rvm/vm/suites/return_undefined_if_not_true.yaml diff --git a/src/rvm/instructions/display.rs b/src/rvm/instructions/display.rs index 8454f8de..4fd526c4 100644 --- a/src/rvm/instructions/display.rs +++ b/src/rvm/instructions/display.rs @@ -143,6 +143,8 @@ impl core::fmt::Display for Instruction { Instruction::LoadBool { dest, value } => format!("LOAD_BOOL R({}) {}", dest, value), Instruction::LoadData { dest } => format!("LOAD_DATA R({})", dest), Instruction::LoadInput { dest } => format!("LOAD_INPUT R({})", dest), + Instruction::LoadContext { dest } => format!("LOAD_CONTEXT R({})", dest), + Instruction::LoadMetadata { dest } => format!("LOAD_METADATA R({})", dest), Instruction::Move { dest, src } => format!("MOVE R({}) R({})", dest, src), Instruction::Add { dest, left, right } => { format!("ADD R({}) R({}) R({})", dest, left, right) @@ -220,6 +222,9 @@ impl core::fmt::Display for Instruction { } Instruction::ArrayNew { dest } => format!("ARRAY_NEW R({})", dest), Instruction::ArrayPush { arr, value } => format!("ARRAY_PUSH R({}) R({})", arr, value), + Instruction::ArrayPushDefined { arr, value } => { + format!("ARRAY_PUSH_DEFINED R({}) R({})", arr, value) + } Instruction::ArrayCreate { params_index } => { format!("ARRAY_CREATE P({})", params_index) } @@ -247,6 +252,12 @@ impl core::fmt::Display for Instruction { }; format!("{} R({})", name, register) } + Instruction::ReturnUndefinedIfNotTrue { condition } => { + format!("RETURN_UNDEFINED_IF_NOT_TRUE R({})", condition) + } + Instruction::CoalesceUndefinedToNull { register } => { + format!("COALESCE_UNDEF_TO_NULL R({})", register) + } Instruction::LoopStart { params_index } => { format!("LOOP_START P({})", params_index) } diff --git a/src/rvm/instructions/mod.rs b/src/rvm/instructions/mod.rs index 80e43a26..9760d876 100644 --- a/src/rvm/instructions/mod.rs +++ b/src/rvm/instructions/mod.rs @@ -54,6 +54,16 @@ pub enum Instruction { dest: u8, }, + /// Load evaluation context object into register + LoadContext { + dest: u8, + }, + + /// Load program metadata object into register + LoadMetadata { + dest: u8, + }, + /// Move value from one register to another Move { dest: u8, @@ -206,6 +216,16 @@ pub enum Instruction { value: u8, }, + /// Push element to array, but skip if the value is undefined. + /// + /// Used by Azure Policy's `field('alias[*].property')` wildcard collection + /// so that absent nested properties are excluded from the collected array + /// rather than producing undefined entries. + ArrayPushDefined { + arr: u8, + value: u8, + }, + /// Create array from registers - returns undefined if any element is undefined ArrayCreate { /// Index into program's instruction_data.array_create_params table @@ -254,6 +274,25 @@ pub enum Instruction { mode: GuardMode, }, + /// Return undefined immediately when the condition register is not exactly + /// `Bool(true)`. Any other value — including `false`, `Undefined`, `Null`, + /// numbers, strings, etc. — causes an immediate return of `Undefined`. + /// + /// This is used by Azure Policy compilation to model "condition does not match" + /// without treating it as a VM assertion failure. + ReturnUndefinedIfNotTrue { + condition: u8, + }, + + /// Replace Undefined with Null in a register. + /// + /// Azure Policy treats missing fields as null rather than undefined. + /// This instruction prevents the RVM's undefined-propagation from + /// short-circuiting subsequent builtin calls. + CoalesceUndefinedToNull { + register: u8, + }, + /// Start a loop over a collection with specified semantics - uses parameter table LoopStart { /// Index into program's instruction_data.loop_params table diff --git a/src/rvm/program/listing.rs b/src/rvm/program/listing.rs index 18c1054f..4f98789d 100644 --- a/src/rvm/program/listing.rs +++ b/src/rvm/program/listing.rs @@ -307,6 +307,14 @@ fn format_instruction_readable( let base = format!("{}LoadInput r{} ← input", indent, dest); align_comment(&base, "Load global input document", config.comment_column) } + Instruction::LoadContext { dest } => { + let base = format!("{}LoadContext r{} ← context", indent, dest); + align_comment(&base, "Load evaluation context", config.comment_column) + } + Instruction::LoadMetadata { dest } => { + let base = format!("{}LoadMetadata r{} ← metadata", indent, dest); + align_comment(&base, "Load program metadata", config.comment_column) + } Instruction::Move { dest, src } => { let base = format!("{}Move r{} ← r{}", indent, dest, src); let comment = format!("Copy value from r{} to r{}", src, dest); @@ -565,6 +573,11 @@ fn format_instruction_readable( let comment = format!("Append r{} to array r{}", value, arr); align_comment(&base, &comment, config.comment_column) } + Instruction::ArrayPushDefined { arr, value } => { + let base = format!("{}ArrayPushDef r{}.push(r{})", indent, arr, value); + let comment = format!("Append r{} to array r{} (skip if undefined)", value, arr); + align_comment(&base, &comment, config.comment_column) + } Instruction::ArrayCreate { params_index } => instruction_data .get_array_create_params(params_index) .map_or_else( @@ -658,6 +671,25 @@ fn format_instruction_readable( }; align_comment(&keyword, &comment, config.comment_column) } + Instruction::ReturnUndefinedIfNotTrue { condition } => { + let base = format!( + "{}ReturnUndefinedIfNotTrue if r{} != true return undefined", + indent, condition + ); + let comment = format!( + "Return undefined unless r{} is exactly boolean true", + condition + ); + align_comment(&base, &comment, config.comment_column) + } + Instruction::CoalesceUndefinedToNull { register } => { + let base = format!( + "{}CoalesceUndefinedToNull r{} = null if undefined", + indent, register + ); + let comment = format!("Azure Policy: absent field → null (r{})", register); + align_comment(&base, &comment, config.comment_column) + } Instruction::LoopStart { params_index } => { instruction_data.get_loop_params(params_index).map_or_else( || { @@ -959,6 +991,8 @@ const fn get_instruction_name(instruction: &Instruction) -> &'static str { Instruction::LoadBool { .. } => "LOAD_BOOL", Instruction::LoadData { .. } => "LOAD_DATA", Instruction::LoadInput { .. } => "LOAD_INPUT", + Instruction::LoadContext { .. } => "LOAD_CONTEXT", + Instruction::LoadMetadata { .. } => "LOAD_METADATA", Instruction::Move { .. } => "MOVE", Instruction::Add { .. } => "ADD", Instruction::Sub { .. } => "SUB", @@ -984,6 +1018,7 @@ const fn get_instruction_name(instruction: &Instruction) -> &'static str { Instruction::IndexLiteral { .. } => "INDEX_LIT", Instruction::ArrayNew { .. } => "ARRAY_NEW", Instruction::ArrayPush { .. } => "ARRAY_PUSH", + Instruction::ArrayPushDefined { .. } => "ARRAY_PUSH_DEF", Instruction::ArrayCreate { .. } => "ARRAY_CREATE", Instruction::SetNew { .. } => "SET_NEW", Instruction::SetAdd { .. } => "SET_ADD", @@ -996,6 +1031,8 @@ const fn get_instruction_name(instruction: &Instruction) -> &'static str { crate::rvm::instructions::GuardMode::Condition => "ASSERT", crate::rvm::instructions::GuardMode::NotUndefined => "ASSERT_NOT_UNDEF", }, + Instruction::ReturnUndefinedIfNotTrue { .. } => "RET_UNDEF_IF_NOT_TRUE", + Instruction::CoalesceUndefinedToNull { .. } => "COALESCE_NULL", Instruction::LoopStart { .. } => "LOOP_START", Instruction::LoopNext { .. } => "LOOP_NEXT", Instruction::CallRule { .. } => "CALL_RULE", @@ -1024,6 +1061,12 @@ fn format_operation_compact( Instruction::LoadInput { dest } => { format!("{}r{} ← input", indent, dest) } + Instruction::LoadContext { dest } => { + format!("{}r{} ← context", indent, dest) + } + Instruction::LoadMetadata { dest } => { + format!("{}r{} ← metadata", indent, dest) + } Instruction::LoadData { dest } => { format!("{}r{} ← data", indent, dest) } diff --git a/src/rvm/tests/instruction_parser.rs b/src/rvm/tests/instruction_parser.rs index f53cd57b..a38d7982 100644 --- a/src/rvm/tests/instruction_parser.rs +++ b/src/rvm/tests/instruction_parser.rs @@ -31,6 +31,8 @@ pub fn parse_instruction(text: &str) -> Result { "LoadBool" => parse_load_bool(params_text), "LoadData" => parse_load_data(params_text), "LoadInput" => parse_load_input(params_text), + "LoadContext" => parse_load_context(params_text), + "LoadMetadata" => parse_load_metadata(params_text), "Move" => parse_move(params_text), "Add" => parse_add(params_text), "Sub" => parse_sub(params_text), @@ -59,6 +61,7 @@ pub fn parse_instruction(text: &str) -> Result { "ArrayCreate" => parse_array_create(params_text), "SetCreate" => parse_set_create(params_text), "ArrayPush" => parse_array_push(params_text), + "ArrayPushDefined" => parse_array_push_defined(params_text), "SetNew" => parse_set_new(params_text), "SetAdd" => parse_set_add(params_text), "Contains" => parse_contains(params_text), @@ -78,6 +81,8 @@ pub fn parse_instruction(text: &str) -> Result { "ComprehensionAdd" => parse_comprehension_add(params_text), "ComprehensionBegin" => parse_comprehension_start(params_text), "ComprehensionYield" => parse_comprehension_add(params_text), + "ReturnUndefinedIfNotTrue" => parse_return_undefined_if_not_true(params_text), + "CoalesceUndefinedToNull" => parse_coalesce_undefined_to_null(params_text), _ => bail!("Unknown instruction: {}", name), } } else { @@ -414,6 +419,16 @@ fn parse_array_push(params_text: &str) -> Result { }) } +fn parse_array_push_defined(params_text: &str) -> Result { + let params = parse_params(params_text)?; + let arr = get_param_u16(¶ms, "arr")?; + let value = get_param_u16(¶ms, "value")?; + Ok(Instruction::ArrayPushDefined { + arr: arr.try_into().unwrap(), + value: value.try_into().unwrap(), + }) +} + fn parse_array_create(params_text: &str) -> Result { let params = parse_params(params_text)?; let params_index = get_param_u16(¶ms, "params_index")?; @@ -555,6 +570,22 @@ fn parse_load_input(params_text: &str) -> Result { }) } +fn parse_load_context(params_text: &str) -> Result { + let params = parse_params(params_text)?; + let dest = get_param_u16(¶ms, "dest")?; + Ok(Instruction::LoadContext { + dest: dest.try_into().unwrap(), + }) +} + +fn parse_load_metadata(params_text: &str) -> Result { + let params = parse_params(params_text)?; + let dest = get_param_u16(¶ms, "dest")?; + Ok(Instruction::LoadMetadata { + dest: dest.try_into().unwrap(), + }) +} + fn parse_mod(params_text: &str) -> Result { let params = parse_params(params_text)?; let dest = get_param_u16(¶ms, "dest")?; @@ -660,3 +691,19 @@ fn parse_comprehension_add(params_text: &str) -> Result { key_reg, }) } + +fn parse_return_undefined_if_not_true(params_text: &str) -> Result { + let params = parse_params(params_text)?; + let condition = get_param_u16(¶ms, "condition")?; + Ok(Instruction::ReturnUndefinedIfNotTrue { + condition: condition.try_into().unwrap(), + }) +} + +fn parse_coalesce_undefined_to_null(params_text: &str) -> Result { + let params = parse_params(params_text)?; + let register = get_param_u16(¶ms, "register")?; + Ok(Instruction::CoalesceUndefinedToNull { + register: register.try_into().unwrap(), + }) +} diff --git a/src/rvm/tests/vm.rs b/src/rvm/tests/vm.rs index c899071c..6c2e3fdd 100644 --- a/src/rvm/tests/vm.rs +++ b/src/rvm/tests/vm.rs @@ -83,6 +83,12 @@ mod tests { data: Option, #[serde(default)] input: Option, + #[serde(default)] + context: Option, + #[serde(default)] + metadata_language: Option, + #[serde(default)] + metadata_annotations: Option>, literals: Vec, #[serde(default)] rule_infos: Vec, @@ -266,6 +272,9 @@ mod tests { instruction_params: Option, data: Option, input: Option, + context: Option, + metadata_language: Option, + metadata_annotations: Option>, max_instructions: Option, host_await_responses: Option>, host_await_responses_run_to_completion: Option>, @@ -285,6 +294,12 @@ mod tests { None }; + let processed_context = if let Some(ref context_value) = context { + Some(process_value(context_value)?) + } else { + None + }; + let processed_rule_tree = if let Some(ref tree_value) = rule_tree { Some(process_value(tree_value)?) } else { @@ -631,6 +646,18 @@ mod tests { program.max_rule_window_size = 255; program.dispatch_window_size = 50; + // Recompute derived flags since instructions were assigned directly + // (bypassing add_instruction which normally tracks has_host_await) + program.recompute_host_await_presence(); + + // Set metadata if provided + if let Some(lang) = metadata_language { + program.metadata.language = lang; + } + if let Some(annotations) = metadata_annotations { + program.metadata.annotations = annotations; + } + // Initialize resolved builtins if we have builtin info if !program.builtin_info_table.is_empty() { if let Err(e) = program.initialize_resolved_builtins() { @@ -664,6 +691,10 @@ mod tests { vm.set_input(input_value); } + if let Some(context_value) = processed_context.clone() { + vm.set_context(context_value); + } + if let Some(limit) = max_instructions { vm.set_max_instructions(limit); } @@ -931,6 +962,9 @@ mod tests { test_case.instruction_params.clone(), test_case.data.clone(), test_case.input.clone(), + test_case.context.clone(), + test_case.metadata_language.clone(), + test_case.metadata_annotations.clone(), test_case.max_instructions, test_case.host_await_responses.clone(), test_case.host_await_responses_run_to_completion.clone(), diff --git a/src/rvm/vm/comprehension.rs b/src/rvm/vm/comprehension.rs index 989d23d9..2e2408cf 100644 --- a/src/rvm/vm/comprehension.rs +++ b/src/rvm/vm/comprehension.rs @@ -312,7 +312,7 @@ impl RegoVM { *current_item = Some(self.get_register(comprehension_context.value_reg)?.clone()); } - IterationState::Array { .. } => {} + IterationState::Array { .. } | IterationState::Single { .. } => {} } iter_state.advance(); @@ -468,7 +468,7 @@ impl RegoVM { } => { *current_item = Some(iteration_value.clone()); } - IterationState::Array { .. } => {} + IterationState::Array { .. } | IterationState::Single { .. } => {} } iter_state.advance(); @@ -599,7 +599,7 @@ impl RegoVM { } => { *current_item = Some(self.get_register(value_reg)?.clone()); } - IterationState::Array { .. } => {} + IterationState::Array { .. } | IterationState::Single { .. } => {} } Ok(()) diff --git a/src/rvm/vm/context.rs b/src/rvm/vm/context.rs index b290de43..c4c8509c 100644 --- a/src/rvm/vm/context.rs +++ b/src/rvm/vm/context.rs @@ -41,6 +41,13 @@ pub enum IterationState { current_item: Option, first_iteration: bool, }, + /// Virtual single-element iteration for non-collection values. + /// Used by Azure Policy's `[*]` on scalar/null fields: presents a single + /// "virtual" element to iterate over, which is always `Null` regardless + /// of the underlying source value. + Single { + consumed: bool, + }, } impl IterationState { @@ -59,6 +66,11 @@ impl IterationState { } => { *first_iteration = false; } + Self::Single { + ref mut consumed, .. + } => { + *consumed = true; + } } } } diff --git a/src/rvm/vm/dispatch.rs b/src/rvm/vm/dispatch.rs index 317a6560..24021ff4 100644 --- a/src/rvm/vm/dispatch.rs +++ b/src/rvm/vm/dispatch.rs @@ -72,6 +72,14 @@ impl RegoVM { self.set_register(dest, self.input.clone())?; Ok(InstructionOutcome::Continue) } + LoadContext { dest } => { + self.set_register(dest, self.context.clone())?; + Ok(InstructionOutcome::Continue) + } + LoadMetadata { dest } => { + self.set_register(dest, self.metadata_value.clone())?; + Ok(InstructionOutcome::Continue) + } Move { dest, src } => { let value = self.get_register(src)?.clone(); self.set_register(dest, value)?; @@ -351,6 +359,21 @@ impl RegoVM { self.handle_condition(passed)?; Ok(InstructionOutcome::Continue) } + ReturnUndefinedIfNotTrue { condition } => { + let value = self.get_register(condition)?; + if matches!(value, Value::Bool(true)) { + Ok(InstructionOutcome::Continue) + } else { + Ok(InstructionOutcome::Return(Value::Undefined)) + } + } + CoalesceUndefinedToNull { register } => { + let value = self.get_register(register)?; + if matches!(value, Value::Undefined) { + self.set_register(register, Value::Null)?; + } + Ok(InstructionOutcome::Continue) + } other => self.execute_call_instruction(program, other), } } @@ -585,6 +608,33 @@ impl RegoVM { } Ok(InstructionOutcome::Continue) } + ArrayPushDefined { arr, value } => { + let value_to_push = self.get_register(value)?.clone(); + + // Skip undefined values — matches Azure Policy's + // `field('alias[*].property')` collection semantics where + // absent nested properties are excluded from the collected + // array. + if value_to_push == Value::Undefined { + return Ok(InstructionOutcome::Continue); + } + + let mut arr_value = self.take_register(arr)?; + + if let Ok(arr_mut) = arr_value.as_array_mut() { + arr_mut.push(value_to_push); + self.set_register(arr, arr_value)?; + } else { + let offending = arr_value.clone(); + self.set_register(arr, arr_value)?; + return Err(VmError::RegisterNotArray { + register: arr, + value: offending, + pc: self.pc, + }); + } + Ok(InstructionOutcome::Continue) + } ArrayCreate { params_index } => { if let Some(params) = program .instruction_data diff --git a/src/rvm/vm/loops.rs b/src/rvm/vm/loops.rs index 1984683d..57458a76 100644 --- a/src/rvm/vm/loops.rs +++ b/src/rvm/vm/loops.rs @@ -10,7 +10,12 @@ use super::execution_model::{ExecutionFrame, ExecutionMode, FrameKind}; use super::machine::RegoVM; /// Result for a loop over a non-iterable value (null, string, number, bool, Undefined). -/// `Every` over empty is vacuously `true`. +/// In standard Rego mode, this helper is used for all loop modes when the +/// collection operand is not iterable: `Every` over empty is vacuously `true`, +/// and `Any`/`ForEach` over empty are `false`. +/// In Azure Policy mode, this helper is still used for `Any`/`ForEach` when an +/// object or other non-collection is encountered and short-circuits to `false`; +/// `Every` with virtual elements is handled via a different code path. #[inline] const fn non_collection_result(mode: &LoopMode) -> Value { match *mode { @@ -437,15 +442,29 @@ impl RegoVM { })) } Value::Object(ref obj) => { - if obj.is_empty() { - self.handle_empty_collection(mode, params.result_reg, params.loop_end)?; - return Ok(None); + if self.virtual_element_on_non_collection { + // Azure Policy: `[*]` expects an array. Objects are + // treated as non-collections — virtual element for Every + // mode, immediate false for Any/ForEach. + if *mode == LoopMode::Every { + Ok(Some(IterationState::Single { consumed: false })) + } else { + let result = non_collection_result(mode); + self.set_register(params.result_reg, result)?; + self.pc = usize::from(params.loop_end).saturating_sub(1); + Ok(None) + } + } else { + if obj.is_empty() { + self.handle_empty_collection(mode, params.result_reg, params.loop_end)?; + return Ok(None); + } + Ok(Some(IterationState::Object { + obj: obj.clone(), + current_key: None, + first_iteration: true, + })) } - Ok(Some(IterationState::Object { - obj: obj.clone(), - current_key: None, - first_iteration: true, - })) } Value::Set(ref set) => { if set.is_empty() { @@ -459,10 +478,17 @@ impl RegoVM { })) } _ => { - let result = non_collection_result(mode); - self.set_register(params.result_reg, result)?; - self.pc = usize::from(params.loop_end).saturating_sub(1); - Ok(None) + if self.virtual_element_on_non_collection && *mode == LoopMode::Every { + // Azure Policy: allOf [*] on non-collection iterates once + // over a virtual null element. + Ok(Some(IterationState::Single { consumed: false })) + } else { + // Standard Rego or count/forEach: non-collection → immediate result. + let result = non_collection_result(mode); + self.set_register(params.result_reg, result)?; + self.pc = usize::from(params.loop_end).saturating_sub(1); + Ok(None) + } } } } @@ -576,6 +602,20 @@ impl RegoVM { Ok(false) } } + IterationState::Single { ref consumed } => { + if *consumed { + Ok(false) + } else { + // Virtual single element: key=0, value=Null. + // Sub-field accesses on Null produce Undefined, which is + // what Azure Policy expects for missing/non-array [*]. + if key_reg != value_reg { + self.set_register(key_reg, Value::from(0))?; + } + self.set_register(value_reg, Value::Null)?; + Ok(true) + } + } } } diff --git a/src/rvm/vm/machine.rs b/src/rvm/vm/machine.rs index f075289c..d48f3789 100644 --- a/src/rvm/vm/machine.rs +++ b/src/rvm/vm/machine.rs @@ -49,6 +49,9 @@ pub struct RegoVM { /// Global input object pub(super) input: Value, + /// Evaluation context: host-supplied ambient data available via LoadContext + pub(super) context: Value, + /// Loop execution stack /// Note: Loops are either at the outermost level (rule body) or within the topmost comprehension. /// Loops never contain comprehensions - it's always the other way around. @@ -136,6 +139,18 @@ pub struct RegoVM { /// Cached args Vec for builtin calls (avoids Vec allocation per call) pub(super) cached_builtin_args: Vec, + + /// When `true`, an `Every` loop over a non-collection value (null, string, + /// number, etc.) behaves as if iterating over a single virtual element whose + /// value is `Null`, instead of being vacuously `true` over an empty collection. + /// This matches Azure Policy semantics where `field[*]` on a non-array produces + /// a single `Null` element (which typically causes the condition to evaluate to + /// `false`). Automatically set from `program.metadata.language`. + pub(super) virtual_element_on_non_collection: bool, + + /// Cached `Value` representation of `program.metadata`, computed once in + /// `load_program()` and reused by `LoadMetadata` instructions. + pub(super) metadata_value: Value, } impl Default for RegoVM { @@ -157,6 +172,7 @@ impl RegoVM { rule_cache: Vec::new(), data: Value::Null, input: Value::Null, + context: Value::Undefined, loop_stack: Vec::new(), call_rule_stack: Vec::new(), register_stack: Vec::new(), @@ -182,6 +198,8 @@ impl RegoVM { dummy_span: None, dummy_exprs: Vec::new(), cached_builtin_args: Vec::new(), + virtual_element_on_non_collection: false, + metadata_value: Value::Undefined, } } @@ -210,6 +228,12 @@ impl RegoVM { // Set PC to main entry point self.pc = usize::try_from(program.main_entry_point).unwrap_or(0); self.executed_instructions = 0; // Reset instruction counter + + // Azure Policy: `Every` over non-collection → false (not vacuous true) + self.virtual_element_on_non_collection = program.metadata.language == "azure_policy"; + + // Cache the metadata as a Value for LoadMetadata instructions + self.metadata_value = program.metadata.to_value(); } /// Set the compiled policy for default rule evaluation @@ -246,6 +270,11 @@ impl RegoVM { self.input = input; } + /// Set the evaluation context (host-supplied ambient data) + pub fn set_context(&mut self, context: Value) { + self.context = context; + } + /// Get the number of entry points available pub fn get_entry_point_count(&self) -> usize { self.program.entry_points.len() @@ -390,7 +419,7 @@ impl RegoVM { } pub(super) fn execution_timer_tick(&mut self, work_units: u32) -> Result<()> { - if !self.execution_timer.accumulate(work_units) { + if self.execution_timer.limit().is_none() { return Ok(()); } @@ -399,7 +428,7 @@ impl RegoVM { }; self.execution_timer - .check_now(now) + .tick(work_units, now) .map_err(|err| match err { LimitError::TimeLimitExceeded { elapsed, limit } => VmError::TimeLimitExceeded { elapsed, @@ -505,8 +534,8 @@ impl RegoVM { } #[cfg(all(feature = "allocator-memory-limits", not(miri)))] - fn map_limit_error(&self, err: LimitError) -> VmError { - match err { + pub(super) fn memory_check(&mut self) -> Result<()> { + limits::check_memory_limit_if_needed().map_err(|err| match err { LimitError::MemoryLimitExceeded { usage, limit } => VmError::MemoryLimitExceeded { usage, limit, @@ -516,12 +545,7 @@ impl RegoVM { message: format!("unexpected limit error: {other}"), pc: self.pc, }, - } - } - - #[cfg(all(feature = "allocator-memory-limits", not(miri)))] - pub(super) fn memory_check(&mut self) -> Result<()> { - limits::check_memory_limit_if_needed().map_err(|err| self.map_limit_error(err)) + }) } #[cfg(any(miri, not(feature = "allocator-memory-limits")))] diff --git a/tests/rvm/vm/suites/array_push_defined.yaml b/tests/rvm/vm/suites/array_push_defined.yaml new file mode 100644 index 00000000..37904382 --- /dev/null +++ b/tests/rvm/vm/suites/array_push_defined.yaml @@ -0,0 +1,92 @@ +# ArrayPushDefined instruction test suite +# +# Like ArrayPush but silently skips Undefined values. Used by the Azure Policy +# compiler when collecting `field('alias[*].property')` results: absent nested +# properties produce Undefined and should be excluded from the collected array. +# +# Semantics: +# - If value register is Undefined → no-op (skip). +# - If value register is any other value (including Null) → push to array. +# - If arr register is not an array → error. + +cases: + # ========================================================================= + # Undefined values are skipped + # ========================================================================= + + - note: push_undefined_is_skipped + description: Pushing an undefined value leaves the array unchanged + literals: [] + instructions: + - "ArrayNew { dest: 0 }" + # Register 1 is implicitly Undefined + - "ArrayPushDefined { arr: 0, value: 1 }" + - "Return { value: 0 }" + want_result: [] + + - note: push_undefined_among_defined + description: Only defined values are collected; undefined ones are silently dropped + literals: + - 10 + - 20 + instructions: + - "ArrayNew { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "ArrayPushDefined { arr: 0, value: 1 }" + # Register 2 is Undefined — should be skipped + - "ArrayPushDefined { arr: 0, value: 2 }" + - "Load { dest: 3, literal_idx: 1 }" + - "ArrayPushDefined { arr: 0, value: 3 }" + - "Return { value: 0 }" + want_result: [10, 20] + + # ========================================================================= + # Null and other values are kept + # ========================================================================= + + - note: push_null_is_kept + description: Null is not undefined — it is pushed to the array + literals: [] + instructions: + - "ArrayNew { dest: 0 }" + - "LoadNull { dest: 1 }" + - "ArrayPushDefined { arr: 0, value: 1 }" + - "Return { value: 0 }" + want_result: [null] + + - note: push_bool_is_kept + description: Boolean value is pushed normally + literals: + - true + instructions: + - "ArrayNew { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "ArrayPushDefined { arr: 0, value: 1 }" + - "Return { value: 0 }" + want_result: [true] + + - note: push_string_is_kept + description: String value is pushed normally + literals: + - "hello" + instructions: + - "ArrayNew { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "ArrayPushDefined { arr: 0, value: 1 }" + - "Return { value: 0 }" + want_result: ["hello"] + + # ========================================================================= + # Non-array target is an error + # ========================================================================= + + - note: push_to_non_array_errors + description: ArrayPushDefined on a non-array register produces an error + literals: + - 42 + - 1 + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "Load { dest: 1, literal_idx: 1 }" + - "ArrayPushDefined { arr: 0, value: 1 }" + want_error: "Register 0 does not contain an array" diff --git a/tests/rvm/vm/suites/coalesce_undefined_to_null.yaml b/tests/rvm/vm/suites/coalesce_undefined_to_null.yaml new file mode 100644 index 00000000..3bc3e5ac --- /dev/null +++ b/tests/rvm/vm/suites/coalesce_undefined_to_null.yaml @@ -0,0 +1,194 @@ +# CoalesceUndefinedToNull instruction test suite +# +# Used by the Azure Policy compiler to convert undefined (missing) field values +# to null before passing them to comparison operators. This prevents undefined +# propagation from short-circuiting subsequent builtins. +# +# Semantics: +# - If register == Undefined → replaces it with Null (in-place). +# - For any other value (null, bool, number, string, array, object) → no-op. + +cases: + # ========================================================================= + # Basic coalescing behavior + # ========================================================================= + + - note: undefined_becomes_null + description: Undefined register value is replaced with null + literals: [] + instructions: + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: null + + - note: null_unchanged + description: Null value is not modified + literals: [] + instructions: + - "LoadNull { dest: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: null + + - note: true_unchanged + description: Boolean true is not modified + literals: [] + instructions: + - "LoadTrue { dest: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: true + + - note: false_unchanged + description: Boolean false is not modified + literals: [] + instructions: + - "LoadFalse { dest: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: false + + - note: number_unchanged + description: Number value is not modified + literals: + - 42 + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: 42 + + - note: string_unchanged + description: String value is not modified + literals: + - "hello" + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: "hello" + + - note: empty_string_unchanged + description: Empty string is not modified (not confused with undefined) + literals: + - "" + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: "" + + - note: array_unchanged + description: Array value is not modified + literals: [] + instructions: + - "ArrayNew { dest: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + want_result: [] + + # ========================================================================= + # Multiple coalesces — only undefined registers are affected + # ========================================================================= + + - note: selective_coalescing + description: Only undefined registers are coalesced; others remain unchanged + literals: + - 99 + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "CoalesceUndefinedToNull { register: 1 }" + - "ArrayNew { dest: 2 }" + - "ArrayPush { arr: 2, value: 0 }" + - "ArrayPush { arr: 2, value: 1 }" + - "Return { value: 2 }" + want_result: [99, null] + + # ========================================================================= + # Interaction with ReturnUndefinedIfNotTrue + # ========================================================================= + + - note: coalesce_then_guard_null_returns_undefined + description: > + Coalescing undefined to null, then passing to ReturnUndefinedIfNotTrue + returns undefined (null is not true) + literals: + - "should not reach" + instructions: + - "CoalesceUndefinedToNull { register: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Return { value: 1 }" + want_result: "#undefined" + + - note: coalesce_preserves_true_for_guard + description: > + Coalescing a true value is a no-op, so ReturnUndefinedIfNotTrue + continues execution + literals: + - "reached" + instructions: + - "LoadTrue { dest: 0 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Return { value: 1 }" + want_result: "reached" + + # ========================================================================= + # Interaction with HostAwait (suspendable mode) + # ========================================================================= + + - note: coalesce_after_host_await_undefined_response + description: > + HostAwait returns undefined, CoalesceUndefinedToNull converts it to null + literals: + - "await-id" + - "arg" + instructions: + - "Load { dest: 1, literal_idx: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "HostAwait { dest: 0, arg: 1, id: 2 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + host_await_responses: + - id: "await-id" + value: "#undefined" + want_result: null + + - note: coalesce_after_host_await_value_response + description: > + HostAwait returns a real value, CoalesceUndefinedToNull is a no-op + literals: + - "await-id" + - "arg" + instructions: + - "Load { dest: 1, literal_idx: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "HostAwait { dest: 0, arg: 1, id: 2 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + host_await_responses: + - id: "await-id" + value: "real-value" + want_result: "real-value" + + - note: suspendable_coalesce_after_host_await + description: > + In suspendable mode: HostAwait suspends and resumes with value, + CoalesceUndefinedToNull is a no-op + literals: + - "await-id" + - "arg" + instructions: + - "Load { dest: 1, literal_idx: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "HostAwait { dest: 0, arg: 1, id: 2 }" + - "CoalesceUndefinedToNull { register: 0 }" + - "Return { value: 0 }" + host_await_responses_suspendable: + - id: "await-id" + value: 42 + ignore_run_to_completion_hostawait_failure: true + want_result: 42 diff --git a/tests/rvm/vm/suites/load_context_metadata.yaml b/tests/rvm/vm/suites/load_context_metadata.yaml new file mode 100644 index 00000000..0783b97c --- /dev/null +++ b/tests/rvm/vm/suites/load_context_metadata.yaml @@ -0,0 +1,379 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# LoadContext and LoadMetadata Test Suite +# Tests LoadContext and LoadMetadata instructions +# Covers: no context, populated context, context indexing, metadata fields, +# annotations, metadata round-trip, and interaction with other loads + +cases: + # ── LoadContext ──────────────────────────────────────────────────────────── + + - note: load_context_undefined + description: LoadContext returns Undefined when no context is set + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: "#undefined" + + - note: load_context_empty_object + description: LoadContext returns empty object + context: {} + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: {} + + - note: load_context_string + description: LoadContext returns a string value + context: "hello world" + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: "hello world" + + - note: load_context_number + description: LoadContext returns a numeric value + context: 42 + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: 42 + + - note: load_context_boolean + description: LoadContext returns a boolean value + context: true + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: true + + - note: load_context_with_values + description: LoadContext returns a populated object + context: + api_version: "2021-06-01" + tenant_id: "abc-123" + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: + api_version: "2021-06-01" + tenant_id: "abc-123" + + - note: load_context_nested + description: LoadContext returns deeply nested data + context: + level1: + level2: + value: "deep" + literals: [] + instructions: + - "LoadContext { dest: 0 }" + - "Return { value: 0 }" + want_result: + level1: + level2: + value: "deep" + + - note: load_context_and_index + description: LoadContext followed by indexing into context data + context: + api_version: "2021-06-01" + tenant_id: "abc-123" + literals: + - "api_version" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + want_result: "2021-06-01" + + - note: load_context_index_missing_key + description: Loading context and indexing a missing key yields Undefined + context: + api_version: "2021-06-01" + literals: + - "nonexistent" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + want_result: "#undefined" + + - note: load_context_deep_index + description: LoadContext with chained indexing into nested object + context: + request: + headers: + content_type: "application/json" + literals: + - "request" + - "headers" + - "content_type" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Load { dest: 3, literal_idx: 1 }" + - "Index { dest: 4, container: 2, key: 3 }" + - "Load { dest: 5, literal_idx: 2 }" + - "Index { dest: 6, container: 4, key: 5 }" + - "Return { value: 6 }" + want_result: "application/json" + + - note: load_context_with_array + description: LoadContext with an array value + context: + tags: + - "production" + - "east-us" + literals: + - "tags" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Count { dest: 3, collection: 2 }" + - "Return { value: 3 }" + want_result: 2 + + - note: load_context_multiple_times + description: Loading context multiple times returns the same value + context: + value: 42 + literals: + - "value" + instructions: + - "LoadContext { dest: 0 }" + - "LoadContext { dest: 1 }" + - "Eq { dest: 2, left: 0, right: 1 }" + - "Return { value: 2 }" + want_result: true + + - note: load_context_independent_of_input + description: Context and Input are independent + input: + input_key: "from_input" + context: + context_key: "from_context" + literals: + - "context_key" + - "input_key" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "LoadInput { dest: 3 }" + - "Load { dest: 4, literal_idx: 1 }" + - "Index { dest: 5, container: 3, key: 4 }" + # Check context value + - "Load { dest: 6, literal_idx: 0 }" # reuse the "context_key" literal for comparison + - "Return { value: 2 }" + want_result: "from_context" + + - note: load_context_independent_of_data + description: Context and Data are independent + data: + data_key: "from_data" + context: + context_key: "from_context" + literals: + - "context_key" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + want_result: "from_context" + + # ── LoadMetadata ──────────────────────────────────────────────────────────── + + - note: load_metadata_default + description: LoadMetadata returns object with metadata fields - check by indexing language + literals: + - "language" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + # Default language is empty string + want_result: "" + + - note: load_metadata_language + description: LoadMetadata returns language field when set + metadata_language: "azure_policy" + literals: + - "language" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + want_result: "azure_policy" + + - note: load_metadata_annotations_string + description: LoadMetadata includes string annotations + metadata_language: "azure_policy" + metadata_annotations: + policy_mode: "indexed" + policy_category: "Security" + literals: + - "annotations" + - "policy_mode" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Load { dest: 3, literal_idx: 1 }" + - "Index { dest: 4, container: 2, key: 3 }" + - "Return { value: 4 }" + want_result: "indexed" + + - note: load_metadata_annotations_number + description: LoadMetadata includes numeric annotations + metadata_annotations: + max_retries: 3 + literals: + - "annotations" + - "max_retries" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Load { dest: 3, literal_idx: 1 }" + - "Index { dest: 4, container: 2, key: 3 }" + - "Return { value: 4 }" + want_result: 3 + + - note: load_metadata_annotations_boolean + description: LoadMetadata includes boolean annotations + metadata_annotations: + is_audit: true + literals: + - "annotations" + - "is_audit" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Load { dest: 3, literal_idx: 1 }" + - "Index { dest: 4, container: 2, key: 3 }" + - "Return { value: 4 }" + want_result: true + + - note: load_metadata_annotations_nested + description: LoadMetadata includes nested annotations (object) + metadata_annotations: + compliance: + framework: "nist" + controls: + - "AC-1" + - "AC-2" + literals: + - "annotations" + - "compliance" + - "framework" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Load { dest: 3, literal_idx: 1 }" + - "Index { dest: 4, container: 2, key: 3 }" + - "Load { dest: 5, literal_idx: 2 }" + - "Index { dest: 6, container: 4, key: 5 }" + - "Return { value: 6 }" + want_result: "nist" + + - note: load_metadata_annotations_absent + description: LoadMetadata without annotations - indexing annotations gives Undefined + literals: + - "annotations" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "Return { value: 2 }" + want_result: "#undefined" + + - note: load_metadata_multiple_times + description: Loading metadata multiple times returns same value + metadata_language: "rego" + literals: + - "language" + instructions: + - "LoadMetadata { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + - "LoadMetadata { dest: 3 }" + - "Load { dest: 4, literal_idx: 0 }" + - "Index { dest: 5, container: 3, key: 4 }" + - "Eq { dest: 6, left: 2, right: 5 }" + - "Return { value: 6 }" + want_result: true + + # ── Combined LoadContext + LoadMetadata ────────────────────────────────── + + - note: context_and_metadata_independent + description: Context and metadata are independent values + context: + env: "production" + metadata_language: "rego" + literals: + - "env" + - "language" + instructions: + - "LoadContext { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + # r2 = "production" + - "LoadMetadata { dest: 3 }" + - "Load { dest: 4, literal_idx: 1 }" + - "Index { dest: 5, container: 3, key: 4 }" + # r5 = "rego" + - "Ne { dest: 6, left: 2, right: 5 }" + - "Return { value: 6 }" + want_result: true + + - note: all_four_loads + description: LoadData, LoadInput, LoadContext, LoadMetadata all coexist + data: + role: "admin" + input: + action: "read" + context: + region: "us-east" + metadata_language: "test" + literals: + - "role" + - "action" + - "region" + - "language" + instructions: + - "LoadData { dest: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Index { dest: 2, container: 0, key: 1 }" + # r2 = "admin" + - "LoadInput { dest: 3 }" + - "Load { dest: 4, literal_idx: 1 }" + - "Index { dest: 5, container: 3, key: 4 }" + # r5 = "read" + - "LoadContext { dest: 6 }" + - "Load { dest: 7, literal_idx: 2 }" + - "Index { dest: 8, container: 6, key: 7 }" + # r8 = "us-east" + - "LoadMetadata { dest: 9 }" + - "Load { dest: 10, literal_idx: 3 }" + - "Index { dest: 11, container: 9, key: 10 }" + # r11 = "test" + # Return context region as witness + - "Return { value: 8 }" + want_result: "us-east" diff --git a/tests/rvm/vm/suites/return_undefined_if_not_true.yaml b/tests/rvm/vm/suites/return_undefined_if_not_true.yaml new file mode 100644 index 00000000..c9b0170a --- /dev/null +++ b/tests/rvm/vm/suites/return_undefined_if_not_true.yaml @@ -0,0 +1,209 @@ +# ReturnUndefinedIfNotTrue instruction test suite +# +# This instruction is used by the Azure Policy compiler to short-circuit +# evaluation when a policy condition does not match. +# +# Semantics: +# - If condition register == true (Bool), execution continues to the next instruction. +# - For ANY other value (false, null, undefined, numbers, strings, arrays, objects), +# the instruction immediately returns Value::Undefined. + +cases: + # ========================================================================= + # Basic behavior — true continues, everything else returns undefined + # ========================================================================= + + - note: true_continues + description: When condition is true, execution continues and the next value is returned + literals: + - "success" + instructions: + - "LoadTrue { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "Load { dest: 1, literal_idx: 0 }" + - "Return { value: 1 }" + want_result: "success" + + - note: false_returns_undefined + description: When condition is false, returns undefined immediately + literals: [] + instructions: + - "LoadFalse { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "Return { value: 1 }" + want_result: "#undefined" + + - note: null_returns_undefined + description: When condition is null, returns undefined + literals: [] + instructions: + - "LoadNull { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "Return { value: 1 }" + want_result: "#undefined" + + - note: undefined_returns_undefined + description: When condition is undefined (uninitialized register), returns undefined + literals: [] + instructions: + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "Return { value: 1 }" + want_result: "#undefined" + + - note: number_returns_undefined + description: Numbers are not boolean true — returns undefined + literals: + - 1 + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "Return { value: 1 }" + want_result: "#undefined" + + - note: string_returns_undefined + description: Non-empty strings are not boolean true — returns undefined + literals: + - "truthy" + instructions: + - "Load { dest: 0, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "Return { value: 1 }" + want_result: "#undefined" + + # ========================================================================= + # Multiple guards in sequence + # ========================================================================= + + - note: two_guards_both_true + description: Two consecutive guards — both true, execution reaches the end + literals: + - "final" + instructions: + - "LoadTrue { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "ReturnUndefinedIfNotTrue { condition: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "Return { value: 2 }" + want_result: "final" + + - note: two_guards_first_false + description: First guard fails — immediately returns undefined, second guard not reached + literals: + - "final" + instructions: + - "LoadFalse { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadTrue { dest: 1 }" + - "ReturnUndefinedIfNotTrue { condition: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "Return { value: 2 }" + want_result: "#undefined" + + - note: two_guards_second_false + description: First guard passes, second fails — returns undefined + literals: + - "final" + instructions: + - "LoadTrue { dest: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 0 }" + - "LoadFalse { dest: 1 }" + - "ReturnUndefinedIfNotTrue { condition: 1 }" + - "Load { dest: 2, literal_idx: 0 }" + - "Return { value: 2 }" + want_result: "#undefined" + + # ========================================================================= + # Combined with HostAwait (the auditIfNotExists pattern) + # ========================================================================= + + - note: host_await_then_guard_true + description: > + Simulates auditIfNotExists: HostAwait returns true (non-compliant), + guard passes, effect string is returned + literals: + - "auditIfNotExists" + - "azure.policy.audit_if_not_exists" + - "request" + instructions: + - "Load { dest: 1, literal_idx: 2 }" + - "Load { dest: 2, literal_idx: 1 }" + - "HostAwait { dest: 3, arg: 1, id: 2 }" + - "Load { dest: 4, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 3 }" + - "Return { value: 4 }" + host_await_responses: + - id: "azure.policy.audit_if_not_exists" + value: true + want_result: "auditIfNotExists" + + - note: host_await_then_guard_false + description: > + Simulates auditIfNotExists: HostAwait returns false (compliant), + guard fails, returns undefined (no policy violation) + literals: + - "auditIfNotExists" + - "azure.policy.audit_if_not_exists" + - "request" + instructions: + - "Load { dest: 1, literal_idx: 2 }" + - "Load { dest: 2, literal_idx: 1 }" + - "HostAwait { dest: 3, arg: 1, id: 2 }" + - "Load { dest: 4, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 3 }" + - "Return { value: 4 }" + host_await_responses: + - id: "azure.policy.audit_if_not_exists" + value: false + want_result: "#undefined" + + # ========================================================================= + # Suspendable mode — HostAwait + ReturnUndefinedIfNotTrue + # ========================================================================= + + - note: suspendable_host_await_guard_true + description: > + In suspendable mode: HostAwait suspends, resumes with true, + ReturnUndefinedIfNotTrue continues, returns effect + literals: + - "deny" + - "await-id" + - "arg" + instructions: + - "Load { dest: 1, literal_idx: 2 }" + - "Load { dest: 2, literal_idx: 1 }" + - "HostAwait { dest: 3, arg: 1, id: 2 }" + - "Load { dest: 4, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 3 }" + - "Return { value: 4 }" + host_await_responses_suspendable: + - id: "await-id" + value: true + ignore_run_to_completion_hostawait_failure: true + want_result: "deny" + + - note: suspendable_host_await_guard_false + description: > + In suspendable mode: HostAwait suspends, resumes with false, + ReturnUndefinedIfNotTrue returns undefined + literals: + - "deny" + - "await-id" + - "arg" + instructions: + - "Load { dest: 1, literal_idx: 2 }" + - "Load { dest: 2, literal_idx: 1 }" + - "HostAwait { dest: 3, arg: 1, id: 2 }" + - "Load { dest: 4, literal_idx: 0 }" + - "ReturnUndefinedIfNotTrue { condition: 3 }" + - "Return { value: 4 }" + host_await_responses_suspendable: + - id: "await-id" + value: false + ignore_run_to_completion_hostawait_failure: true + want_result: "#undefined" diff --git a/tests/rvm/vm/suites/type_errors.yaml b/tests/rvm/vm/suites/type_errors.yaml index d6796d54..975d9950 100644 --- a/tests/rvm/vm/suites/type_errors.yaml +++ b/tests/rvm/vm/suites/type_errors.yaml @@ -96,7 +96,7 @@ cases: want_error: "#undefined" - note: logical_not_int - description: NOT with non-boolean defined operand should yield false + description: NOT with int operand treats non-boolean as truthy (result false) example_rego: "not 42" literals: - 42