diff --git a/src/interpreter.rs b/src/interpreter.rs index e44490a4..7f7e154f 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -1782,6 +1782,7 @@ impl Interpreter { let mut comps = self.eval_rule_ref(&rule_ref)?; if let Some(ke) = &key_expr { + is_const_rule = is_const_rule && Self::is_simple_literal(ke)?; comps.push(self.eval_expr(ke)?); } let output = if let Some(oe) = &output_expr { diff --git a/src/languages/rego/compiler/error.rs b/src/languages/rego/compiler/error.rs index d413fbe4..5e0860a8 100644 --- a/src/languages/rego/compiler/error.rs +++ b/src/languages/rego/compiler/error.rs @@ -63,6 +63,12 @@ pub enum CompilerError { #[error("Invalid function expression with package")] InvalidFunctionExpressionWithPackage, + #[error("partial object rules with constant keys are not yet supported by the RVM compiler")] + PartialObjectConstantKeyUnsupported, + + #[error("partial object rules with nested bracket keys are not yet supported by the RVM compiler")] + PartialObjectNestedKeyUnsupported, + #[error("Compilation error: {message}")] General { message: String }, } diff --git a/src/languages/rego/compiler/rules.rs b/src/languages/rego/compiler/rules.rs index 990bef3c..88337247 100644 --- a/src/languages/rego/compiler/rules.rs +++ b/src/languages/rego/compiler/rules.rs @@ -59,7 +59,7 @@ impl<'a> Compiler<'a> { crate::ast::Expr::RefBrack { .. } if assign.is_some() => { RuleType::PartialObject } - crate::ast::Expr::RefBrack { .. } => RuleType::PartialSet, + crate::ast::Expr::RefBrack { .. } => RuleType::PartialObject, _ => RuleType::Complete, }, _ => RuleType::Complete, @@ -88,6 +88,52 @@ impl<'a> Compiler<'a> { }) } + fn validate_partial_object_shape(&self, refr: &ExprRef) -> Result<()> { + let Expr::RefBrack { + refr: prefix, + index, + .. + } = refr.as_ref() + else { + return Ok(()); + }; + + if Self::has_unsupported_bracket_prefix(prefix) { + return Err(CompilerError::PartialObjectNestedKeyUnsupported.at(refr.span())); + } + + if Self::is_simple_literal(index) { + return Err(CompilerError::PartialObjectConstantKeyUnsupported.at(index.span())); + } + + Ok(()) + } + + fn has_unsupported_bracket_prefix(expr: &ExprRef) -> bool { + match expr.as_ref() { + Expr::RefBrack { refr, index, .. } => { + !Self::is_string_literal(index) || Self::has_unsupported_bracket_prefix(refr) + } + Expr::RefDot { refr, .. } => Self::has_unsupported_bracket_prefix(refr), + _ => false, + } + } + + fn is_string_literal(expr: &ExprRef) -> bool { + matches!(expr.as_ref(), Expr::String { .. } | Expr::RawString { .. }) + } + + fn is_simple_literal(expr: &ExprRef) -> bool { + matches!( + expr.as_ref(), + Expr::String { .. } + | Expr::RawString { .. } + | Expr::Number { .. } + | Expr::Bool { .. } + | Expr::Null { .. } + ) + } + pub(super) fn get_or_assign_rule_index(&mut self, rule_path: &str) -> Result { if let Some(&index) = self.rule_index_map.get(rule_path) { return Ok(index); @@ -345,6 +391,10 @@ impl<'a> Compiler<'a> { let (key_expr, value_expr) = match head { RuleHead::Compr { refr, assign, .. } => { + if rule_type == RuleType::PartialObject { + self.validate_partial_object_shape(refr)?; + } + self.rule_definition_function_params[rule_index as usize].push(None); self.rule_definition_destructuring_patterns[rule_index as usize] .push(None); diff --git a/tests/interpreter/cases/rule/partial_object_v1.yaml b/tests/interpreter/cases/rule/partial_object_v1.yaml new file mode 100644 index 00000000..109f5a4b --- /dev/null +++ b/tests/interpreter/cases/rule/partial_object_v1.yaml @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: constant_key_partial_object_v1 + data: {} + input: + enabled: true + modules: + - | + package test + import rego.v1 + + p["fixed"] if { + input.enabled + } + query: data.test + want_result: + p: + fixed: true + + - note: multilevel_partial_object_v1 + data: {} + input: + nested: + app: + read: 1 + write: 2 + ops: + deploy: 3 + modules: + - | + package test + import rego.v1 + + p[a][b] if { + some a, obj in input.nested + some b, _ in obj + } + query: data.test + want_result: + p: + app: + read: true + write: true + ops: + deploy: true + + - note: constant_key_partial_object_explicit_value_v1 + data: {} + input: + enabled: true + modules: + - | + package test + import rego.v1 + + p["fixed"] := 7 if { + input.enabled + } + query: data.test + want_result: + p: + fixed: 7 + + - note: multilevel_partial_object_explicit_value_v1 + data: {} + input: + nested: + app: + read: 1 + write: 2 + ops: + deploy: 3 + modules: + - | + package test + import rego.v1 + + p[a][b] := v if { + some a, obj in input.nested + some b, v in obj + } + query: data.test + want_result: + p: + app: + read: 1 + write: 2 + ops: + deploy: 3 + + - note: issue_712_reproducer_v0_partial_set + data: {} + input: + servers: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + import future.keywords.in + + violations[k] { + some k, _ in input.servers + } + query: data.test.violations + want_result: + set!: ["BAR", "BAZ", "FOO"] + + - note: issue_712_reproducer_v1_partial_object + data: {} + input: + servers: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + import rego.v1 + + violations[k] if { + some k, _ in input.servers + } + query: data.test.violations + want_result: + BAR: true + BAZ: true + FOO: true + + - note: issue_712_reproducer_v1_contains_partial_set + data: {} + input: + servers: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + import rego.v1 + + violations contains k if { + some k, _ in input.servers + } + query: data.test.violations + want_result: + set!: ["BAR", "BAZ", "FOO"] diff --git a/tests/rvm/rego/cases/partial_object_rules.yaml b/tests/rvm/rego/cases/partial_object_rules.yaml new file mode 100644 index 00000000..e7f268bb --- /dev/null +++ b/tests/rvm/rego/cases/partial_object_rules.yaml @@ -0,0 +1,939 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: partial_object_variable_key_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + + p[k] if { + some k, _ in input.items + } + query: data.test.p + want_result: + BAR: true + BAZ: true + FOO: true + + - note: partial_object_explicit_value_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + + p[k] := v if { + some k, v in input.items + } + query: data.test.p + want_result: + BAR: 2 + BAZ: 3 + FOO: 1 + + - note: partial_object_dynamic_expression_key_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + aliases: + FOO: alias-foo + BAR: alias-bar + BAZ: alias-baz + modules: + - | + package test + + p[input.aliases[k]] := v if { + some k, v in input.items + } + query: data.test.p + want_result: + alias-bar: 2 + alias-baz: 3 + alias-foo: 1 + + - note: partial_object_undefined_key_skipped + # TODO(#719): RVM incorrectly materializes undefined keys instead of + # skipping iterations where the key is undefined. + skip: true + data: {} + input: + items: + FOO: 1 + BAR: 2 + aliases: + FOO: alias-foo + modules: + - | + package test + + p[input.aliases[k]] := v if { + some k, v in input.items + } + query: data.test.p + want_result: + alias-foo: 1 + + - note: partial_object_duplicate_key_last_wins + # TODO(#719): regorus silently overwrites conflicting keys instead of + # erroring when the same key is produced with different values. + skip: true + data: {} + input: {} + modules: + - | + package test + + p[k] := v if { + some k, v in {"a": 1} + } + + p[k] := v if { + some k, v in {"a": 2} + } + query: data.test.p + want_error: "conflict" + + - note: partial_object_duplicate_key_same_value_ok + data: {} + input: {} + modules: + - | + package test + + p[k] if { + some k, _ in {"a": 1} + } + + p[k] if { + some k, _ in {"a": 2} + } + query: data.test.p + want_result: + a: true + + - note: partial_object_single_element_input + data: {} + input: + items: + ONLY: 1 + modules: + - | + package test + + p[k] if { + some k, _ in input.items + } + query: data.test.p + want_result: + ONLY: true + + - note: partial_object_static_bracket_prefix_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + + p["a"][k] := v if { + some k, v in input.items + } + query: data.test.p.a + want_result: + BAR: 2 + BAZ: 3 + FOO: 1 + + - note: partial_object_constant_key_unsupported_in_rvm + data: {} + input: + enabled: true + modules: + - | + package test + + p["fixed"] if { + input.enabled + } + query: data.test.p.fixed + want_error: "partial object rules with constant keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_constant_key_explicit_value_unsupported_in_rvm + data: {} + input: + enabled: true + modules: + - | + package test + + p["fixed"] := 7 if { + input.enabled + } + query: data.test.p.fixed + want_error: "partial object rules with constant keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_multiple_bodies_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + + p[k] if { + some k, _ in input.items + k in {"FOO", "BAR"} + } + + p[k] if { + some k, _ in input.items + k == "BAZ" + } + query: data.test.p + want_result: + BAR: true + BAZ: true + FOO: true + + - note: partial_set_contains_collects_all_bindings + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + + p contains k if { + some k, _ in input.items + } + query: data.test.p + want_result: + set!: ["BAR", "BAZ", "FOO"] + + - note: issue_712_reproducer_v1_partial_object + data: {} + input: + servers: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + import rego.v1 + + violations[k] if { + some k, _ in input.servers + } + query: data.test.violations + want_result: + BAR: true + BAZ: true + FOO: true + + - note: issue_712_reproducer_v1_contains_partial_set + data: {} + input: + servers: + FOO: 1 + BAR: 2 + BAZ: 3 + modules: + - | + package test + import rego.v1 + + violations contains k if { + some k, _ in input.servers + } + query: data.test.violations + want_result: + set!: ["BAR", "BAZ", "FOO"] + + - note: partial_object_multilevel_key_unsupported_in_rvm + data: {} + input: + nested: + app: + read: 1 + write: 2 + ops: + deploy: 3 + modules: + - | + package test + + p[a][b] if { + some a, obj in input.nested + some b, _ in obj + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_multilevel_key_explicit_value_unsupported_in_rvm + data: {} + input: + nested: + app: + read: 1 + write: 2 + ops: + deploy: 3 + modules: + - | + package test + + p[a][b] := v if { + some a, obj in input.nested + some b, v in obj + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_hidden_dynamic_prefix_unsupported_in_rvm + data: {} + input: + nested: + app: + q: + read: 1 + write: 2 + ops: + q: + deploy: 3 + modules: + - | + package test + + p[a].q[b] if { + some a, obj in input.nested + some b, _ in obj.q + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_array_iteration_collects_all_bindings + data: {} + input: + items: ["FOO", "BAR", "BAZ"] + modules: + - | + package test + + p[v] if { + some _, v in input.items + } + query: data.test.p + want_result: + BAR: true + BAZ: true + FOO: true + + - note: partial_object_empty_input_is_empty_object + data: {} + input: + items: {} + modules: + - | + package test + + p[k] if { + some k, _ in input.items + } + query: data.test.p + want_result: {} + + - note: partial_object_duplicate_paths_same_key_same_value_deduplicates + data: {} + input: + pairs: + - alias: shared + value: 1 + - alias: alpha + value: 10 + - alias: shared + value: 1 + modules: + - | + package test + + p[entry.alias] := entry.value if { + some entry in input.pairs + } + query: data.test.p + want_result: + alpha: 10 + shared: 1 + + - note: partial_object_duplicate_paths_same_key_different_values_conflict + # TODO(#719): regorus silently overwrites conflicting keys instead of + # erroring when the same key is produced with different values. + skip: true + data: {} + input: + pairs: + - alias: shared + value: 1 + - alias: shared + value: 2 + modules: + - | + package test + + p[entry.alias] := entry.value if { + some entry in input.pairs + } + query: data.test.p + want_error: "conflict" + + - note: partial_object_undefined_key_skips_iteration + # TODO(#719): RVM incorrectly materializes undefined keys instead of + # skipping iterations where the key is undefined. + skip: true + data: {} + input: + items: + FOO: 1 + BAR: 2 + BAZ: 3 + aliases: + FOO: alias-foo + BAZ: alias-baz + modules: + - | + package test + + p[input.aliases[k]] := v if { + some k, v in input.items + } + query: data.test.p + want_result: + alias-baz: 3 + alias-foo: 1 + + - note: partial_object_undefined_value_skips_iteration + # TODO(#719): RVM incorrectly materializes undefined values instead of + # skipping iterations where the value is undefined. + skip: true + data: {} + input: + keys: ["FOO", "BAR", "BAZ"] + values: + FOO: 1 + BAZ: 3 + modules: + - | + package test + + p[k] := input.values[k] if { + some _, k in input.keys + } + query: data.test.p + want_result: + BAZ: 3 + FOO: 1 + + - note: partial_object_mixed_undefined_key_value_cases_skip_bad_iterations + # TODO(#719): RVM incorrectly materializes undefined keys/values instead of + # skipping iterations where the key or value is undefined. + skip: true + data: {} + input: + rows: + - src: keep + - src: missing_alias + - src: missing_value + - src: missing_both + aliases: + keep: alias-keep + missing_value: alias-no-value + values: + keep: 1 + missing_alias: 2 + modules: + - | + package test + + p[input.aliases[row.src]] := input.values[row.src] if { + some row in input.rows + } + query: data.test.p + want_result: + alias-keep: 1 + + - note: partial_object_and_partial_set_same_name_conflict + data: {} + input: + items: + FOO: 1 + modules: + - | + package test + + p[k] if { + some k, _ in input.items + } + + p contains "shadow" if { + true + } + query: data.test.p + want_error: "has multiple types" + + - note: partial_object_complete_rule_conflicts_with_partial_object + data: {} + input: + items: + a: 1 + modules: + - | + package test + + p := {"fixed": 1} + + p[k] := v if { + some k, v in input.items + } + query: data.test.p + want_error: "multiple types" + + - note: partial_object_partial_set_conflicts_with_partial_object + data: {} + input: + items: + a: 1 + modules: + - | + package test + + p contains k if { + some k, _ in input.items + } + + p[k] := 1 if { + some k, _ in input.items + } + query: data.test.p + want_error: "multiple types" + + - note: partial_object_large_range_counts_all_entries + data: {} + input: {} + modules: + - | + package test + + p[key] := n if { + n := numbers.range(0, 255)[_] + key := sprintf("k-%d", [n]) + } + + main := count(p) + query: data.test.main + want_result: 256 + + - note: partial_object_rbac_duplicate_actions_deduplicate + data: + role_permissions: + reader: ["read", "list"] + writer: ["read", "write"] + auditor: ["read", "list"] + input: + user_roles: ["reader", "writer", "auditor"] + modules: + - | + package test + + allowed_actions[action] if { + some role in input.user_roles + some action in data.role_permissions[role] + } + query: data.test.allowed_actions + want_result: + list: true + read: true + write: true + + - note: partial_object_violations_real_world_pattern + data: {} + input: + spec: + containers: + - name: api + securityContext: + readOnlyRootFilesystem: false + - name: worker + securityContext: + readOnlyRootFilesystem: true + - name: sidecar + modules: + - | + package test + + violations[msg] if { + some container in input.spec.containers + not container.securityContext.readOnlyRootFilesystem + msg := sprintf("Container %s must use readOnlyRootFilesystem", [container.name]) + } + query: data.test.violations + want_result: + Container api must use readOnlyRootFilesystem: true + Container sidecar must use readOnlyRootFilesystem: true + + - note: partial_object_resource_mapping_filters_valid_resources + data: {} + input: + resources: + svc-api: + cpu: 1 + job-cleanup: + cpu: 2 + svc-worker: + cpu: 4 + modules: + - | + package test + + valid_resource(name) if { + startswith(name, "svc-") + } + + resources[name] := config if { + some name, config in input.resources + valid_resource(name) + } + query: data.test.resources + want_result: + svc-api: + cpu: 1 + svc-worker: + cpu: 4 + + - note: partial_object_computed_concat_key_constant_body + data: {} + modules: + - | + package test + + p[concat("", ["edge", "-", "key"])] if { + true + } + query: data.test.p + want_result: + edge-key: true + + - note: partial_object_duplicate_computed_key_same_value_merges + data: {} + input: + items: + A: 0 + a: 0 + modules: + - | + package test + + p[lower(k)] := 1 if { + some k, _ in input.items + } + query: data.test.p + want_result: + a: 1 + + - note: partial_object_function_key_and_object_value + data: {} + input: + items: + a: 1 + b: 2 + modules: + - | + package test + + f(x) := concat(":", [x, "suffix"]) + + p[f(k)] := {"nested": v + 1} if { + some k, v in input.items + } + query: data.test.p + want_result: + "a:suffix": + nested: 2 + "b:suffix": + nested: 3 + + - note: partial_object_array_index_key_uses_selected_elements + data: {} + input: + keys: ["alpha", "beta"] + values: [10, 20] + modules: + - | + package test + + p[input.keys[i]] := v if { + some i, v in input.values + } + query: data.test.p + want_result: + alpha: 10 + beta: 20 + + - note: partial_object_computed_empty_and_special_string_keys + data: {} + modules: + - | + package test + + p[concat("", [""])] := "empty" if { + true + } + + p[concat("", ["a/b?c#d"])] := "special" if { + true + } + query: data.test.p + want_result: + "": "empty" + a/b?c#d: "special" + + - note: partial_object_not_filters_blocked_entries + data: {} + input: + items: + allowed: true + blocked: true + blocked: + blocked: true + modules: + - | + package test + + p[k] if { + some k, _ in input.items + not input.blocked[k] + } + query: data.test.p + want_result: + allowed: true + + - note: partial_object_dot_bracket_object_value_collects_all_bindings + data: {} + input: + items: + a: 1 + b: 2 + modules: + - | + package test + + p.config[k] := {"nested": v} if { + some k, v in input.items + } + query: data.test.p.config + want_result: + a: + nested: 1 + b: + nested: 2 + + - note: partial_object_dynamic_prefix_static_suffix_unsupported_in_rvm + data: {} + input: + items: + a: 1 + b: 2 + modules: + - | + package test + + p[k]["fixed"] := upper(k) if { + some k, _ in input.items + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_literal_prefix_nested_unsupported_in_rvm + data: {} + input: + items: + a: 1 + b: 2 + modules: + - | + package test + + p[1][k] := v if { + some k, v in input.items + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_three_level_nested_dynamic_unsupported_in_rvm + data: {} + input: + nested: + app: + read: 1 + write: 2 + ops: + deploy: 3 + modules: + - | + package test + + p["root"][a][b] := v if { + some a, obj in input.nested + some b, v in obj + } + + main := p + query: data.test.main + want_error: "partial object rules with nested bracket keys are not yet supported by the RVM compiler" + allow_interpreter_success: true + + - note: partial_object_with_body_unsupported_in_rvm + data: {} + input: + enabled: false + items: + a: 1 + modules: + - | + package test + + gate if { + input.enabled + } + + p[k] := v if { + some k, v in input.items + data.test.gate with input as {"enabled": true} + } + query: data.test.p + want_error: "the `with` keyword is not supported by the compiler yet" + allow_interpreter_success: true + + - note: partial_object_every_vacuous_truth_collects_empty_arrays + # TODO(#719): RVM currently includes the failing `bad` group here, while the + # interpreter returns only `empty` and `ok` (expected per vacuous truth + # semantics). + skip: true + data: {} + input: + groups: + ok: [1, 2] + bad: [1, 0] + empty: [] + modules: + - | + package test + + p[k] if { + some k, arr in input.groups + every v in arr { + v > 0 + } + } + query: data.test.p + want_result: + empty: true + ok: true + + - note: partial_object_join_var_multiple_bindings + data: + a: ["1", "2", "3", "4"] + g: + a: ["1", "0", "0", "0"] + b: ["0", "2", "0", "0"] + c: ["0", "0", "0", "4"] + modules: + - | + package test + + p[k] := v if { + data.a[i] = v + data.g[k][i] = v + } + query: data.test.p + want_result: + a: "1" + b: "2" + c: "4" + + - note: partial_object_composite_value + data: + g: + a: [1, 0, 0, 0] + b: [0, 2, 0, 0] + c: [0, 0, 0, 4] + modules: + - | + package test + + p[k] := [i, {"v2": v}] if { + data.g[k] = x + x[i] = v + v != 0 + } + query: data.test.p + want_result: + a: [0, {v2: 1}] + b: [1, {v2: 2}] + c: [3, {v2: 4}] + + - note: partial_object_true_semantics_dedupes_duplicate_keys + data: {} + modules: + - | + package test + + p[k] if { + ks := ["a", "b", "c", "a"] + ks[_] = k + } + query: data.test.p + want_result: + a: true + b: true + c: true