diff --git a/bindings/csharp/API.md b/bindings/csharp/API.md index ae1cd7e1..576b4416 100644 --- a/bindings/csharp/API.md +++ b/bindings/csharp/API.md @@ -167,6 +167,62 @@ public static class Compiler } ``` +### Engine + +`AddPolicy(path, rego)` and `AddPolicyFromFile(path)` reject an embedded NUL +in `path` with `ArgumentException` instead of allowing the native call to +silently truncate it. + +The stateful `Engine` API can inspect one loaded module for rule declarations +without evaluating the policy. Call +`HasDeclaredRuleRootedAt(sourcePath, rootName)` with either a bare root such as +`"metadata"` or a dotted rule path such as `"metadata.parameters"`. A selector +matches that exact path and its component-wise descendants: `"metadata"` +matches `metadata.parameters`, `metadata.parameters.child`, and +`metadata.other`, while `"metadata.parameters"` matches `metadata.parameters` +and `metadata.parameters.child`, but not +`metadata.other` or `metadata.parametersExtra`. + +The method scans authored rule heads in only the selected module, including +defaults, functions, sets, and rules whose bodies would be false or undefined. +Imports, references, comments, strings, and object keys are not declarations; +rules in other modules in the same package are not included. + +```csharp +using var engine = new Engine(); +engine.AddPolicy( + "customer.rego", + "package customer\nparams.timeout := input.timeout"); + +bool hasParams = engine.HasDeclaredRuleRootedAt("customer.rego", "params"); +``` + +`sourcePath` must identify exactly one loaded module. For `AddPolicy`, the +stored source label is the supplied path. For `AddPolicyFromFile`, the path is +converted to a string lossily by the native implementation; the lookup does +not compare the original path bytes. Non-UTF-8 filenames can therefore +produce the same source label and an ambiguous-source error. Source selection +occurs before validating `rootName`, so missing or ambiguous paths remain +`InvalidOperationException` even when the selector is invalid. + +`rootName` must be a nonempty path accepted by the module's load-time Rego mode +and future-keyword imports. It is parsed as a native rule reference: only a +root identifier followed by zero or more dot-qualified fields is accepted; +whitespace, comments, bracket selectors, calls, and trailing syntax are not. +Path components compare exactly, not by string prefix. Invalid selectors +throw `ArgumentException` with `ParamName` set to `rootName`. A missing or +ambiguous source, or a rule head that cannot be classified, throws +`InvalidOperationException`. Null strings throw `ArgumentNullException`; +embedded NULs in either string throw `ArgumentException` before native +marshalling. + +The native call validates the engine and acquires its read lock before source +transport, then validates the root string. A null or invalid-UTF-8 root is an +invalid argument; source-path transport errors retain the normal operation +error status. After valid string transport, exact source selection precedes +selector grammar validation, so a missing or ambiguous source remains an +`InvalidOperationException` even when `rootName` is invalid. + ### PolicyModule Represents a single policy module to be compiled. Each PolicyModule corresponds to a Rego file (.rego), and each Rego file defines a Rego package using the `package` declaration at the top of the file. @@ -542,7 +598,7 @@ catch (Exception ex) Some functionality requires specific Rust feature flags: -- **azure_policy**: Required for target-aware compilation and policy parameters +- **azure_policy**: Required for target-aware compilation and the `GetPolicyParameters` metadata API - Without this feature, target-related methods will not be available ## Version Compatibility diff --git a/bindings/csharp/Regorus.Tests/RegorusTests.cs b/bindings/csharp/Regorus.Tests/RegorusTests.cs index 6fabdd4f..e1978391 100644 --- a/bindings/csharp/Regorus.Tests/RegorusTests.cs +++ b/bindings/csharp/Regorus.Tests/RegorusTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -27,30 +28,50 @@ public void Basic_evaluation_succeeds() Assert.AreEqual("\"Hello\"", result); } - [TestMethod] - public void Rule_conflict_preserves_error_status_and_reports_previous_location() - { - using var engine = new Engine(); - engine.AddPolicy( - @"C:\policy files\first.rego", - "package test\np := 1\n"); - engine.AddPolicy( - @"C:\policy files\second.rego", - "package test\np := 2\n"); - - var ex = Assert.ThrowsException( - () => engine.EvalRule("data.test.p")); - - StringAssert.Contains( - ex.Message, - @"rule conflicts with rule at C:\policy files\first.rego:2:1"); - StringAssert.Contains(ex.Message, @"C:\policy files\second.rego:2:1"); - StringAssert.Contains(ex.Message, "p := 2"); - Assert.IsFalse(ex.Message.Contains("p := 1", StringComparison.Ordinal)); - Assert.IsFalse(ex.Message.Contains("defined here", StringComparison.Ordinal)); - Assert.IsFalse(ex.Message.Contains('"')); - } - + [TestMethod] + public void AddPolicy_rejects_embedded_nul_in_path() + { + using var engine = new Engine(); + + Assert.ThrowsException( + () => engine.AddPolicy("a.rego\0suffix", "package test\nx := true")); + } + + [TestMethod] + public void AddPolicyFromFile_rejects_embedded_nul_in_path() + { + using var engine = new Engine(); + engine.SetRegoV0(true); + + Assert.ThrowsException( + () => engine.AddPolicyFromFile( + Path.Combine(AppContext.BaseDirectory, "tests", "aci", "framework.rego") + "\0suffix")); + } + + [TestMethod] + public void Rule_conflict_preserves_error_status_and_reports_previous_location() + { + using var engine = new Engine(); + engine.AddPolicy( + @"C:\policy files\first.rego", + "package test\np := 1\n"); + engine.AddPolicy( + @"C:\policy files\second.rego", + "package test\np := 2\n"); + + var ex = Assert.ThrowsException( + () => engine.EvalRule("data.test.p")); + + StringAssert.Contains( + ex.Message, + @"rule conflicts with rule at C:\policy files\first.rego:2:1"); + StringAssert.Contains(ex.Message, @"C:\policy files\second.rego:2:1"); + StringAssert.Contains(ex.Message, "p := 2"); + Assert.IsFalse(ex.Message.Contains("p := 1", StringComparison.Ordinal)); + Assert.IsFalse(ex.Message.Contains("defined here", StringComparison.Ordinal)); + Assert.IsFalse(ex.Message.Contains('"')); + } + [TestMethod] public void Evaluation_using_file_policies_succeeds() { @@ -264,6 +285,107 @@ public void GetPolicyParameters_succeeds() Assert.AreEqual("b", modifierName); } + [TestMethod] + public void HasDeclaredRuleRootedAt_checks_bare_and_dotted_prefixes() + { + using var engine = new Engine(); + engine.AddPolicy( + "metadata.rego", + "package customer\nmetadata.parameters.child := false"); + engine.AddPolicy( + "default.rego", + "package customer\ndefault metadata.parameters = false"); + engine.AddPolicy( + "function.rego", + "package customer\nmetadata.lookup(value) := value"); + engine.AddPolicy( + "set.rego", + "package customer\nmetadata contains \"item\""); + engine.AddPolicy( + "lookalike.rego", + "package customer\nmetadataExtra := true"); + engine.AddPolicy( + "reference.rego", + "package customer\nimport data.shared as metadata\nuse := metadata.value\nconfig := {\"metadata\": true}"); + + Assert.IsTrue(engine.HasDeclaredRuleRootedAt("metadata.rego", "metadata")); + Assert.IsTrue(engine.HasDeclaredRuleRootedAt("metadata.rego", "metadata.parameters")); + Assert.IsTrue(engine.HasDeclaredRuleRootedAt("default.rego", "metadata")); + Assert.IsTrue(engine.HasDeclaredRuleRootedAt("function.rego", "metadata")); + Assert.IsTrue(engine.HasDeclaredRuleRootedAt("set.rego", "metadata")); + Assert.IsFalse(engine.HasDeclaredRuleRootedAt("lookalike.rego", "metadata")); + Assert.IsFalse(engine.HasDeclaredRuleRootedAt("reference.rego", "metadata")); + } + + [TestMethod] + public void HasDeclaredRuleRootedAt_maps_invalid_root_to_argument_exception() + { + using var engine = new Engine(); + engine.AddPolicy("a.rego", "package customer\nmetadata := true"); + + var error = Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("a.rego", "metadata..parameters")); + Assert.AreEqual("rootName", error.ParamName); + } + + [TestMethod] + public void HasDeclaredRuleRootedAt_preserves_source_errors_and_string_guards() + { + using var engine = new Engine(); + engine.AddPolicy("duplicate.rego", "package customer\nmetadata := false"); + engine.AddPolicy("duplicate.rego", "package customer\nx := true"); + + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("duplicate.rego", "metadata..parameters")); + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("missing.rego", "metadata..parameters")); + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt(null!, "metadata")); + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("duplicate.rego", null!)); + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("duplicate.rego\0.rego", "metadata")); + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("duplicate.rego", "metadata\0parameters")); + } + + [TestMethod] + public void HasDeclaredRuleRootedAt_preserves_captured_file_limit_errors() + { + using var engine = new Engine(); + engine.SetPolicyLengthConfig(new PolicyLengthConfig(128, (nuint)64, (nuint)4)); + engine.AddPolicy("limits.rego", "package customer\nx := true"); + engine.SetPolicyLengthConfig(new PolicyLengthConfig(128, (nuint)128, (nuint)4)); + + var rootName = new string('a', 65); + var error = Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("limits.rego", rootName)); + + StringAssert.Contains(error.Message, "maximum allowed policy file size 64 bytes"); + } + + [TestMethod] + public void HasDeclaredRuleRootedAt_keeps_unclassifiable_heads_as_operation_errors() + { + using var engine = new Engine(); + engine.AddPolicy( + "unclassifiable.rego", + "package customer\nmetadata := true\nbroken[lookup()] := true"); + + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("unclassifiable.rego", "metadata")); + } + + [TestMethod] + public void HasDeclaredRuleRootedAt_preserves_disposed_handle_behavior() + { + var engine = new Engine(); + engine.Dispose(); + + Assert.ThrowsException( + () => engine.HasDeclaredRuleRootedAt("a.rego", "metadata")); + } + [TestMethod] public void Global_memory_limit_can_be_set_and_cleared() { diff --git a/bindings/csharp/Regorus/Engine.cs b/bindings/csharp/Regorus/Engine.cs index 852be978..04b439ed 100644 --- a/bindings/csharp/Regorus/Engine.cs +++ b/bindings/csharp/Regorus/Engine.cs @@ -111,8 +111,11 @@ public void ClearPolicyLengthConfig() }); } + /// The path contains an embedded NUL. public string? AddPolicy(string path, string rego) { + Utf8Marshaller.ThrowIfContainsNul(path, nameof(path)); + return Utf8Marshaller.WithUtf8(path, pathPtr => Utf8Marshaller.WithUtf8(rego, regoPtr => UseHandle(enginePtr => @@ -120,6 +123,42 @@ public void ClearPolicyLengthConfig() ))); } + /// + /// Check whether the policy module identified by its exact source path declares a rule at the selected rule path or a descendant, without evaluating the policy. + /// + /// The exact source label associated with the loaded policy module. + /// A native-grammar rule path, either a root identifier such as metadata or a dotted path such as metadata.parameters. + /// True if the module declares a rule at the selected path or any component-wise descendant. + /// Either string contains an embedded NUL, or the rule path is invalid. + /// The source path or root name is null. + /// + /// No unique loaded module matches the source path or a rule head cannot be classified. + /// + public bool HasDeclaredRuleRootedAt(string sourcePath, string rootName) + { + if (sourcePath is null) + { + throw new ArgumentNullException(nameof(sourcePath)); + } + if (rootName is null) + { + throw new ArgumentNullException(nameof(rootName)); + } + + Utf8Marshaller.ThrowIfContainsNul(sourcePath, nameof(sourcePath)); + Utf8Marshaller.ThrowIfContainsNul(rootName, nameof(rootName)); + + return Utf8Marshaller.WithUtf8(sourcePath, pathPtr => + Utf8Marshaller.WithUtf8(rootName, rootNamePtr => + UseHandle(enginePtr => + ResultHelpers.GetBoolResult( + Regorus.Internal.API.regorus_engine_has_declared_rule_rooted_at( + (Regorus.Internal.RegorusEngine*)enginePtr, + (byte*)pathPtr, + (byte*)rootNamePtr), + nameof(rootName))))); + } + public void SetRegoV0(bool enable) { UseHandle(enginePtr => @@ -128,8 +167,11 @@ public void SetRegoV0(bool enable) }); } + /// The path contains an embedded NUL. public string? AddPolicyFromFile(string path) { + Utf8Marshaller.ThrowIfContainsNul(path, nameof(path)); + return Utf8Marshaller.WithUtf8(path, pathPtr => { return UseHandle(enginePtr => diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs index 6859081d..bdc81946 100644 --- a/bindings/csharp/Regorus/NativeMethods.cs +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -284,6 +284,12 @@ internal static unsafe partial class API [DllImport(LibraryName, EntryPoint = "regorus_engine_add_policy", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern RegorusResult regorus_engine_add_policy(RegorusEngine* engine, byte* path, byte* rego); + /// + /// Check whether the policy module at the exact source path declares a rule at rootName or a component-wise descendant. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_has_declared_rule_rooted_at", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_has_declared_rule_rooted_at(RegorusEngine* engine, byte* path, byte* rootName); + /// /// Add a policy from file. /// diff --git a/bindings/csharp/Regorus/ResultHelpers.cs b/bindings/csharp/Regorus/ResultHelpers.cs index 8ae1a2ec..86546f3a 100644 --- a/bindings/csharp/Regorus/ResultHelpers.cs +++ b/bindings/csharp/Regorus/ResultHelpers.cs @@ -34,13 +34,17 @@ internal static unsafe class ResultHelpers } } - internal static bool GetBoolResult(RegorusResult result) + internal static bool GetBoolResult(RegorusResult result, string? invalidArgumentParamName = null) { try { if (result.status != RegorusStatus.Ok) { var message = Utf8Marshaller.FromUtf8(result.error_message); + if (result.status == RegorusStatus.InvalidArgument && invalidArgumentParamName is not null) + { + throw new ArgumentException(message, invalidArgumentParamName); + } throw result.status.CreateException(message); } diff --git a/bindings/ffi/src/engine.rs b/bindings/ffi/src/engine.rs index 7bdd143d..73142499 100644 --- a/bindings/ffi/src/engine.rs +++ b/bindings/ffi/src/engine.rs @@ -199,6 +199,278 @@ mod panic_tests { } } +#[cfg(all(test, feature = "std"))] +mod declared_rule_tests { + use super::{ + regorus_engine_add_policy, regorus_engine_drop, regorus_engine_has_declared_rule_rooted_at, + regorus_engine_new, + }; + use crate::common::{regorus_result_drop, RegorusDataType, RegorusStatus}; + use core::ffi::c_char; + use core::num::{NonZeroU32, NonZeroUsize}; + use core::ptr; + use std::ffi::CString; + + #[test] + fn reports_declared_roots_and_source_errors() { + let engine = regorus_engine_new(); + assert!(!engine.is_null()); + + let path = CString::new("policy.rego").expect("valid path"); + let policy = + CString::new("package customer\nmetadata.parameters := false").expect("valid policy"); + let added = regorus_engine_add_policy(engine, path.as_ptr(), policy.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + + let root = CString::new("metadata").expect("valid root"); + let found = + regorus_engine_has_declared_rule_rooted_at(engine, path.as_ptr(), root.as_ptr()); + assert!(matches!(found.status, RegorusStatus::Ok)); + assert!(matches!(found.data_type, RegorusDataType::Boolean)); + assert!(found.bool_value); + regorus_result_drop(found); + + let dotted_root = CString::new("metadata.parameters").expect("valid root"); + let dotted = + regorus_engine_has_declared_rule_rooted_at(engine, path.as_ptr(), dotted_root.as_ptr()); + assert!(matches!(dotted.status, RegorusStatus::Ok)); + assert!(matches!(dotted.data_type, RegorusDataType::Boolean)); + assert!(dotted.bool_value); + regorus_result_drop(dotted); + + let invalid_root = CString::new("metadata..parameters").expect("valid text"); + let invalid = regorus_engine_has_declared_rule_rooted_at( + engine, + path.as_ptr(), + invalid_root.as_ptr(), + ); + assert!(matches!(invalid.status, RegorusStatus::InvalidArgument)); + regorus_result_drop(invalid); + + let absent_path = CString::new("absent.rego").expect("valid path"); + let absent_policy = + CString::new("package customer\nuse := params.value").expect("valid policy"); + let added = regorus_engine_add_policy(engine, absent_path.as_ptr(), absent_policy.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + + let absent = + regorus_engine_has_declared_rule_rooted_at(engine, absent_path.as_ptr(), root.as_ptr()); + assert!(matches!(absent.status, RegorusStatus::Ok)); + assert!(matches!(absent.data_type, RegorusDataType::Boolean)); + assert!(!absent.bool_value); + regorus_result_drop(absent); + + let missing_path = CString::new("missing.rego").expect("valid path"); + let missing = regorus_engine_has_declared_rule_rooted_at( + engine, + missing_path.as_ptr(), + invalid_root.as_ptr(), + ); + assert!(matches!(missing.status, RegorusStatus::Error)); + regorus_result_drop(missing); + + let duplicate_policy = CString::new("package customer\nx := true").expect("valid policy"); + let added = regorus_engine_add_policy(engine, path.as_ptr(), duplicate_policy.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + + let ambiguous = regorus_engine_has_declared_rule_rooted_at( + engine, + path.as_ptr(), + invalid_root.as_ptr(), + ); + assert!(matches!(ambiguous.status, RegorusStatus::Error)); + regorus_result_drop(ambiguous); + + regorus_engine_drop(engine); + } + + #[test] + fn maps_root_arguments_and_preserves_source_transport_errors() { + let engine = regorus_engine_new(); + assert!(!engine.is_null()); + let path = CString::new("policy.rego").expect("valid path"); + let policy = CString::new("package customer\nmetadata := true").expect("valid policy"); + let added = regorus_engine_add_policy(engine, path.as_ptr(), policy.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + + let root = CString::new("metadata").expect("valid root"); + let null_path = + regorus_engine_has_declared_rule_rooted_at(engine, ptr::null(), root.as_ptr()); + assert!(matches!(null_path.status, RegorusStatus::Error)); + regorus_result_drop(null_path); + + let null_root = + regorus_engine_has_declared_rule_rooted_at(engine, path.as_ptr(), ptr::null()); + assert!(matches!(null_root.status, RegorusStatus::InvalidArgument)); + regorus_result_drop(null_root); + + let invalid_utf8 = [0xff, 0]; + let invalid_path = regorus_engine_has_declared_rule_rooted_at( + engine, + invalid_utf8.as_ptr().cast::(), + root.as_ptr(), + ); + assert!(matches!(invalid_path.status, RegorusStatus::Error)); + regorus_result_drop(invalid_path); + + let invalid_utf8 = [0xff, 0]; + let invalid_root = regorus_engine_has_declared_rule_rooted_at( + engine, + path.as_ptr(), + invalid_utf8.as_ptr().cast::(), + ); + assert!(matches!( + invalid_root.status, + RegorusStatus::InvalidArgument + )); + regorus_result_drop(invalid_root); + + let bad_head_path = CString::new("bad.rego").expect("valid path"); + let bad_head = CString::new("package customer\nmetadata := true\nbroken[lookup()] := true") + .expect("valid policy"); + let added = regorus_engine_add_policy(engine, bad_head_path.as_ptr(), bad_head.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + let unclassifiable = regorus_engine_has_declared_rule_rooted_at( + engine, + bad_head_path.as_ptr(), + root.as_ptr(), + ); + assert!(matches!(unclassifiable.status, RegorusStatus::Error)); + regorus_result_drop(unclassifiable); + + regorus_engine_drop(engine); + } + + #[test] + fn source_transport_errors_follow_engine_and_lock_validation() { + let engine = regorus_engine_new(); + assert!(!engine.is_null()); + + let root = CString::new("metadata").expect("valid root"); + let invalid_utf8 = [0xff, 0]; + + let null_source = + regorus_engine_has_declared_rule_rooted_at(engine, ptr::null(), root.as_ptr()); + let null_source_is_error = matches!(&null_source.status, RegorusStatus::Error); + regorus_result_drop(null_source); + + let invalid_source = regorus_engine_has_declared_rule_rooted_at( + engine, + invalid_utf8.as_ptr().cast::(), + root.as_ptr(), + ); + let invalid_source_message = crate::common::from_c_str(invalid_source.error_message) + .expect("invalid source should report its UTF-8 error"); + let invalid_source_is_utf8_error = matches!(&invalid_source.status, RegorusStatus::Error) + && invalid_source_message.starts_with("invalid utf8:"); + regorus_result_drop(invalid_source); + + let null_engine_source = regorus_engine_has_declared_rule_rooted_at( + ptr::null_mut(), + invalid_utf8.as_ptr().cast::(), + root.as_ptr(), + ); + let null_engine_message = crate::common::from_c_str(null_engine_source.error_message) + .expect("null engine should report its pointer error"); + let null_engine_precedes_source = + matches!(&null_engine_source.status, RegorusStatus::Error) + && null_engine_message == "null pointer"; + regorus_result_drop(null_engine_source); + + let lock_error_precedes_source = { + let engine_ref = unsafe { &*engine }; + let _write_guard = engine_ref.try_write().expect("write lock should succeed"); + let expected_lock_error = engine_ref + .try_read() + .err() + .map(|error| error.to_string()) + .expect("write lock should prevent a read lock"); + let locked_source = regorus_engine_has_declared_rule_rooted_at( + engine, + invalid_utf8.as_ptr().cast::(), + root.as_ptr(), + ); + let locked_source_message = crate::common::from_c_str(locked_source.error_message) + .expect("lock contention should report its error"); + let is_lock_error = matches!(&locked_source.status, RegorusStatus::Error) + && locked_source_message == expected_lock_error; + regorus_result_drop(locked_source); + is_lock_error + }; + let lock_released = unsafe { &*engine }.try_read().is_ok(); + + assert_eq!( + [ + null_source_is_error, + invalid_source_is_utf8_error, + null_engine_precedes_source, + lock_error_precedes_source, + lock_released, + ], + [true; 5], + "source transport must retain Error status and follow engine/read-lock validation, with locks released" + ); + + regorus_engine_drop(engine); + } + + #[test] + fn declared_rule_policy_length_limit_errors_keep_the_operation_error_status() { + let engine = regorus_engine_new(); + assert!(!engine.is_null()); + + let engine_ref = unsafe { &*engine }; + { + let mut guard = engine_ref.try_write().expect("write lock should succeed"); + guard.set_policy_length_config(::regorus::PolicyLengthConfig { + max_col: NonZeroU32::new(64).expect("nonzero"), + max_file_bytes: NonZeroUsize::new(32).expect("nonzero"), + max_lines: NonZeroUsize::new(4).expect("nonzero"), + }); + } + + let path = CString::new("limits.rego").expect("valid path"); + let policy = CString::new("package customer\nx := true").expect("valid policy"); + let added = regorus_engine_add_policy(engine, path.as_ptr(), policy.as_ptr()); + assert!(matches!(added.status, RegorusStatus::Ok)); + regorus_result_drop(added); + + { + let mut guard = engine_ref.try_write().expect("write lock should succeed"); + guard.set_policy_length_config(::regorus::PolicyLengthConfig { + max_col: NonZeroU32::new(1).expect("nonzero"), + max_file_bytes: NonZeroUsize::new(1).expect("nonzero"), + max_lines: NonZeroUsize::new(1).expect("nonzero"), + }); + } + + let at_limit = CString::new("a".repeat(32)).expect("valid root"); + let accepted = + regorus_engine_has_declared_rule_rooted_at(engine, path.as_ptr(), at_limit.as_ptr()); + assert!(matches!(accepted.status, RegorusStatus::Ok)); + assert!(matches!(accepted.data_type, RegorusDataType::Boolean)); + assert!(!accepted.bool_value); + regorus_result_drop(accepted); + + let over_limit = CString::new("a".repeat(33)).expect("valid root"); + let rejected = + regorus_engine_has_declared_rule_rooted_at(engine, path.as_ptr(), over_limit.as_ptr()); + assert!( + matches!(&rejected.status, RegorusStatus::Error), + "expected a policy-length failure to retain Error status, got {:?}", + rejected.status + ); + regorus_result_drop(rejected); + + regorus_engine_drop(engine); + } +} + #[no_mangle] #[cfg(feature = "std")] pub extern "C" fn regorus_engine_test_trigger_panic() -> RegorusResult { @@ -268,6 +540,57 @@ pub extern "C" fn regorus_engine_add_policy( }) } +/// Check whether the module identified by `path` declares a rule at the selected path or a component-wise descendant. +#[no_mangle] +pub extern "C" fn regorus_engine_has_declared_rule_rooted_at( + engine: *mut RegorusEngine, + path: *const c_char, + root_name: *const c_char, +) -> RegorusResult { + with_unwind_guard(|| { + let engine = match to_shared_ref(engine as *const RegorusEngine) { + Ok(engine) => engine, + Err(error) => return to_regorus_result(Err(error)), + }; + let guard = match engine.try_read() { + Ok(guard) => guard, + Err(error) => return to_regorus_result(Err(error)), + }; + let source_path = match from_c_str(path) { + Ok(value) => value, + Err(error) => return to_regorus_result(Err(error)), + }; + if root_name.is_null() { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + "root name is null".into(), + ); + } + let root_name = match from_c_str(root_name) { + Ok(value) => value, + Err(error) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("invalid root name: {error}"), + ) + } + }; + + let output = guard.has_declared_rule_rooted_at(&source_path, &root_name); + match output { + Ok(value) => RegorusResult::ok_bool(value), + Err(error) + if error + .downcast_ref::<::regorus::InvalidRuleRootError>() + .is_some() => + { + RegorusResult::err_with_message(RegorusStatus::InvalidArgument, format!("{error}")) + } + Err(error) => to_regorus_result(Err(error)), + } + }) +} + #[cfg(feature = "std")] #[no_mangle] pub extern "C" fn regorus_engine_add_policy_from_file( diff --git a/src/compiled_policy.rs b/src/compiled_policy.rs index edaa4d35..82d87deb 100644 --- a/src/compiled_policy.rs +++ b/src/compiled_policy.rs @@ -10,6 +10,7 @@ use crate::ast::*; use crate::compiler::hoist::HoistedLoopsLookup; use crate::engine::Engine; +use crate::parser::ParserContext; use crate::scheduler::*; use crate::utils::*; use crate::*; @@ -212,6 +213,7 @@ pub(crate) struct TargetInfo { #[derive(Debug, Clone, Default)] pub(crate) struct CompiledPolicyData { pub(crate) modules: Rc>>, + pub(crate) parser_contexts: Rc>, pub(crate) schedule: Option>, pub(crate) rules: Map>>, pub(crate) default_rules: Map>, diff --git a/src/engine.rs b/src/engine.rs index 97aa6ef9..9ac30194 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -18,11 +18,93 @@ use crate::{Extension, QueryResults}; use crate::Rc; use anyhow::{anyhow, bail, Result}; +/// The supplied rule-root selector is invalid in the selected module's load-time parser context. +#[derive(Debug, thiserror::Error)] +#[error("invalid rule root selector")] +pub struct InvalidRuleRootError; + +fn rule_head_matches_selector_prefix( + expression: &Expr, + selector_components: &[String], + matched_components: &mut usize, +) -> Result { + match *expression { + Expr::Var { + value: Value::String(ref root), + .. + } => { + let Some(expected) = selector_components.get(*matched_components) else { + return Ok(true); + }; + if root.as_ref() != expected { + return Ok(false); + } + *matched_components = matched_components.saturating_add(1); + Ok(true) + } + Expr::Var { .. } => Ok(false), + Expr::RefDot { + ref refr, + ref field, + .. + } => { + if !rule_head_matches_selector_prefix( + refr.as_ref(), + selector_components, + matched_components, + )? { + return Ok(false); + } + let Some(expected) = selector_components.get(*matched_components) else { + return Ok(true); + }; + let name = field.1.as_string()?; + if name.as_ref() != expected { + return Ok(false); + } + *matched_components = matched_components.saturating_add(1); + Ok(true) + } + Expr::RefBrack { + ref refr, + ref index, + .. + } => { + if !rule_head_matches_selector_prefix( + refr.as_ref(), + selector_components, + matched_components, + )? { + return Ok(false); + } + let Some(expected) = selector_components.get(*matched_components) else { + return Ok(true); + }; + match *index.as_ref() { + Expr::String { + value: Value::String(ref name), + .. + } => { + if name.as_ref() != expected { + return Ok(false); + } + *matched_components = matched_components.saturating_add(1); + Ok(true) + } + Expr::Bool { .. } | Expr::Null { .. } | Expr::Number { .. } => Ok(false), + _ => bail!("cannot classify unresolved bracketed rule-head component"), + } + } + _ => bail!("cannot classify rule-head reference"), + } +} + /// The Rego evaluation engine. /// #[derive(Debug, Clone)] pub struct Engine { modules: Rc>>, + parser_contexts: Rc>, interpreter: Interpreter, prepared: bool, rego_v1: bool, @@ -81,6 +163,7 @@ impl Engine { pub fn new() -> Self { let mut engine = Self { modules: Rc::new(vec![]), + parser_contexts: Rc::new(vec![]), interpreter: Interpreter::new(), prepared: false, rego_v1: true, @@ -248,13 +331,78 @@ impl Engine { )?; let mut parser = self.make_parser(&source)?; let module = Ref::new(parser.parse()?); + let parser_context = parser.snapshot_context(self.policy_length_config); limits::enforce_memory_limit().map_err(|err| anyhow!(err))?; Rc::make_mut(&mut self.modules).push(module.clone()); + Rc::make_mut(&mut self.parser_contexts).push(parser_context); // if policies change, interpreter needs to be prepared again self.prepared = false; Interpreter::get_path_string(&module.package.refr, Some("data")) } + /// Check whether a loaded policy module declares a rule at `root_name` or a component-wise descendant. + /// + /// `source_path` must exactly identify one loaded module. `root_name` must + /// be a valid rule path in that module's load-time parser context. A + /// declaration at that exact path or any descendant matches. This checks + /// authored rule heads without evaluating the policy. + /// + /// Returns an error if no module or multiple modules have the source path, + /// if `root_name` is not a valid rule path, or if a rule head cannot be + /// classified as a reference. + pub fn has_declared_rule_rooted_at(&self, source_path: &str, root_name: &str) -> Result { + let mut modules = self + .modules + .iter() + .enumerate() + .filter(|&(_, module)| module.package.span.source.get_path() == source_path); + let (module_index, module) = modules + .next() + .ok_or_else(|| anyhow!("no policy module found for source path '{source_path}'"))?; + if modules.next().is_some() { + bail!("multiple policy modules found for source path '{source_path}'"); + } + + let parser_context = self.parser_contexts.get(module_index).ok_or_else(|| { + anyhow!("missing parser context for policy module at index {module_index}") + })?; + let selector_source = parser_context.create_rule_selector_source(root_name)?; + let selector_components = + match parser_context.parse_rule_selector(&selector_source, root_name) { + Ok(components) => components, + Err(error) + if error + .downcast_ref::() + .is_some() => + { + return Err(error) + } + Err(_) => return Err(InvalidRuleRootError.into()), + }; + + let mut found = false; + for rule in &module.policy { + let head = match *rule.as_ref() { + Rule::Spec { ref head, .. } => match *head { + RuleHead::Compr { ref refr, .. } + | RuleHead::Set { ref refr, .. } + | RuleHead::Func { ref refr, .. } => refr, + }, + Rule::Default { ref refr, .. } => refr, + }; + let _ = Parser::get_path_ref_components(head)?; + let mut matched_components = 0; + let matches_prefix = rule_head_matches_selector_prefix( + head.as_ref(), + &selector_components, + &mut matched_components, + )?; + found |= matches_prefix && matched_components == selector_components.len(); + } + + Ok(found) + } + /// Add a policy from a given file. /// /// The policy file will be parsed and converted to AST representation. @@ -286,8 +434,10 @@ impl Engine { )?; let mut parser = self.make_parser(&source)?; let module = Ref::new(parser.parse()?); + let parser_context = parser.snapshot_context(self.policy_length_config); limits::enforce_memory_limit().map_err(|err| anyhow!(err))?; Rc::make_mut(&mut self.modules).push(module.clone()); + Rc::make_mut(&mut self.parser_contexts).push(parser_context); // if policies change, interpreter needs to be prepared again self.prepared = false; Interpreter::get_path_string(&module.package.refr, Some("data")) @@ -1131,7 +1281,8 @@ impl Engine { let analyzer = Analyzer::new(); let schedule = Rc::new(analyzer.analyze(&self.modules)?); - self.interpreter.set_modules(self.modules.clone()); + self.interpreter + .set_modules(self.modules.clone(), self.parser_contexts.clone()); self.interpreter.clear_builtins_cache(); // clean_internal_evaluation_state will set data to an efficient clont of use supplied init_data @@ -1619,8 +1770,10 @@ impl Engine { compiled_policy: Rc, ) -> Self { let modules = compiled_policy.modules.clone(); + let parser_contexts = compiled_policy.parser_contexts.clone(); let mut engine = Self { modules, + parser_contexts, interpreter: Interpreter::new_from_compiled_policy(compiled_policy), rego_v1: true, // Value doesn't matter since this is used only for policy parsing prepared: true, diff --git a/src/interpreter.rs b/src/interpreter.rs index 0ac20eb7..c3ff865e 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -14,6 +14,7 @@ use crate::compiler::hoist::{HoistedLoop, LoopType}; use crate::lexer::*; use crate::lookup::Lookup; use crate::parser::Parser; +use crate::parser::ParserContext; use crate::scheduler::*; use crate::utils::limits::{monotonic_now, ExecutionTimer, ExecutionTimerConfig}; #[cfg(feature = "std")] @@ -328,8 +329,14 @@ impl Interpreter { self.compiled_policy_mut().functions = functions; } - pub fn set_modules(&mut self, modules: Rc>>) { - self.compiled_policy_mut().modules = modules; + pub(crate) fn set_modules( + &mut self, + modules: Rc>>, + parser_contexts: Rc>, + ) { + let compiled_policy = self.compiled_policy_mut(); + compiled_policy.modules = modules; + compiled_policy.parser_contexts = parser_contexts; } pub fn set_loop_hoisting_table(&mut self, table: crate::compiler::hoist::HoistedLoopsLookup) { diff --git a/src/lib.rs b/src/lib.rs index 9ad21479..930992b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,7 +181,7 @@ pub use { pub use compile::{compile_policy_with_entrypoint, PolicyModule}; pub use compiled_policy::CompiledPolicy; -pub use engine::Engine; +pub use engine::{Engine, InvalidRuleRootError}; pub use lexer::Source; pub use policy_info::PolicyInfo; pub use utils::limits::LimitError; diff --git a/src/parser.rs b/src/parser.rs index 8f7f73ee..63cd416c 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -20,7 +20,8 @@ use crate::number::*; use crate::value::*; use crate::*; -use alloc::collections::BTreeMap; +use crate::utils::limits::PolicyLengthConfig; +use alloc::collections::{BTreeMap, BTreeSet}; use core::num::NonZeroU32; use core::str::FromStr; @@ -31,6 +32,39 @@ fn check_memory_limit() -> Result<()> { crate::utils::limits::check_memory_limit_if_needed().map_err(|err| anyhow!(err)) } +#[derive(Clone, Debug)] +pub struct ParserContext { + rego_v1: bool, + future_keywords: BTreeSet, + policy_length_config: PolicyLengthConfig, +} + +impl ParserContext { + pub(crate) fn create_rule_selector_source(&self, selector: &str) -> Result { + Source::from_contents_with_limits( + "".to_string(), + selector.to_string(), + self.policy_length_config.max_file_bytes, + self.policy_length_config.max_lines, + ) + } + + pub(crate) fn parse_rule_selector( + &self, + source: &Source, + selector: &str, + ) -> Result> { + let mut parser = Parser::new_with_max_col(source, self.policy_length_config.max_col)?; + parser.rego_v1 = self.rego_v1; + parser.future_keywords = self + .future_keywords + .iter() + .map(|keyword| (keyword.clone(), None)) + .collect(); + parser.parse_rule_selector(selector) + } +} + #[derive(Clone)] pub struct Parser<'source> { source: Source, @@ -59,7 +93,16 @@ const DEFAULT_MAX_EXPR_DEPTH: usize = 32; impl<'source> Parser<'source> { pub fn new(source: &'source Source) -> Result { + Self::from_lexer(source, Lexer::new(source)) + } + + fn new_with_max_col(source: &'source Source, max_col: NonZeroU32) -> Result { let mut lexer = Lexer::new(source); + lexer.set_max_col(max_col); + Self::from_lexer(source, lexer) + } + + fn from_lexer(source: &'source Source, mut lexer: Lexer<'source>) -> Result { let tok = lexer.next_token()?; Ok(Self { source: source.clone(), @@ -100,6 +143,17 @@ impl<'source> Parser<'source> { self.turn_on_rego_v1(&None) } + pub(crate) fn snapshot_context( + &self, + policy_length_config: PolicyLengthConfig, + ) -> ParserContext { + ParserContext { + rego_v1: self.rego_v1, + future_keywords: self.future_keywords.keys().cloned().collect(), + policy_length_config, + } + } + fn turn_on_rego_v1(&mut self, span: &Option) -> Result<()> { self.rego_v1 = true; for kw in FUTURE_KEYWORDS { @@ -1593,6 +1647,38 @@ impl<'source> Parser<'source> { Ok(term) } + fn parse_rule_selector(&mut self, selector: &str) -> Result> { + let expression = self.parse_rule_ref()?; + if self.tok.0 != TokenKind::Eof { + return Err(self.tok.1.error("expecting EOF")); + } + + if !matches!(&expression, Expr::Var { .. } | Expr::RefDot { .. }) { + bail!("expected a rule-root selector"); + } + let span = expression.span(); + if span.start != 0 || span.text() != selector { + bail!("rule-root selector must match its complete input"); + } + + fn collect_components(expression: &Expr, components: &mut Vec) -> Result<()> { + match expression { + Expr::Var { span, .. } => components.push(span.text().to_string()), + Expr::RefDot { refr, field, .. } => { + collect_components(refr.as_ref(), components)?; + components.push(field.0.text().to_string()); + } + _ => bail!("expected a rule-root selector"), + } + check_memory_limit()?; + Ok(()) + } + + let mut components = Vec::new(); + collect_components(&expression, &mut components)?; + Ok(components) + } + pub fn parse_rule_head(&mut self) -> Result { let mut span = self.tok.1.clone(); diff --git a/src/tests/engine.rs b/src/tests/engine.rs new file mode 100644 index 00000000..ab4cbbe0 --- /dev/null +++ b/src/tests/engine.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{Engine, InvalidRuleRootError, LimitError, PolicyLengthConfig, Source, Value}; +use alloc::boxed::Box; +use alloc::format; +use alloc::string::ToString as _; +use alloc::sync::Arc; +use anyhow::Result; +use core::num::{NonZeroU32, NonZeroUsize}; +use core::sync::atomic::{AtomicUsize, Ordering}; + +fn is_invalid_root(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some() +} + +#[test] +fn has_declared_rule_rooted_at_checks_authored_heads_in_the_exact_module() -> Result<()> { + let declarations = [ + ( + "conditional.rego", + "package customer\nmetadata if { false }", + ), + ("complete.rego", "package customer\nmetadata := false"), + ( + "descendant.rego", + "package customer\nmetadata.parameters.child := null", + ), + ( + "default.rego", + "package customer\ndefault metadata.parameters = false", + ), + ( + "default_child.rego", + "package customer\ndefault metadata.parameters.child = null", + ), + ( + "function.rego", + "package customer\nmetadata.lookup(value) := value", + ), + ("set.rego", "package customer\nmetadata contains \"item\""), + ( + "other_descendant.rego", + "package customer\nmetadata.other := true", + ), + ]; + + let mut engine = Engine::new(); + for (path, policy) in declarations { + engine.add_policy(path.to_string(), policy.to_string())?; + anyhow::ensure!( + engine.has_declared_rule_rooted_at(path, "metadata")?, + "expected metadata declaration in {path}" + ); + } + + engine.add_policy( + "lookalike.rego".to_string(), + "package customer\nmetadataExtra := true".to_string(), + )?; + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("lookalike.rego", "metadata")?, + "metadata must not match the metadataExtra root" + ); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("lookalike.rego", "meta")?, + "matching must use exact root components" + ); + + for path in [ + "conditional.rego", + "complete.rego", + "descendant.rego", + "default.rego", + "default_child.rego", + "function.rego", + "set.rego", + "other_descendant.rego", + ] { + anyhow::ensure!( + !engine.has_declared_rule_rooted_at(path, "params")?, + "metadata declarations must not count as params in {path}" + ); + } + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_accepts_exact_dotted_prefixes() -> Result<()> { + let declarations = [ + ( + "exact.rego", + "package customer\nmetadata.parameters := false", + "metadata.parameters", + true, + ), + ( + "descendant.rego", + "package customer\nmetadata.parameters.child := false", + "metadata.parameters", + true, + ), + ( + "default.rego", + "package customer\ndefault metadata.parameters = false", + "metadata.parameters", + true, + ), + ( + "function.rego", + "package customer\nmetadata.parameters.lookup(value) := value", + "metadata.parameters", + true, + ), + ( + "set.rego", + "package customer\nmetadata.parameters contains \"item\"", + "metadata.parameters", + true, + ), + ( + "sibling.rego", + "package customer\nmetadata.other := true", + "metadata.parameters", + false, + ), + ( + "lookalike.rego", + "package customer\nmetadata.parametersX := true", + "metadata.parameters", + false, + ), + ( + "shorter.rego", + "package customer\nmetadata := true", + "metadata.parameters", + false, + ), + ( + "body_key.rego", + "package customer\nmetadata := {\"parameters\": true}", + "metadata.parameters", + false, + ), + ( + "escaped_string_key.rego", + r#"package customer +metadata["param\u0065ters"] := true"#, + "metadata.parameters", + true, + ), + ( + "quoted_dotted_key.rego", + r#"package customer +metadata["parameters.child"] := true"#, + "metadata.parameters.child", + false, + ), + ( + "boolean_key.rego", + "package customer\nmetadata[true] := true", + "metadata.true", + false, + ), + ( + "null_key.rego", + "package customer\nmetadata[null] := true", + "metadata.null", + false, + ), + ( + "number_key.rego", + "package customer\nmetadata[1] := true", + "metadata.parameters", + false, + ), + ( + "keyword_field.rego", + "package customer\nmetadata.if := true", + "metadata.if", + true, + ), + ( + "boolean_word_field.rego", + "package customer\nmetadata.true := true", + "metadata.true", + true, + ), + ]; + + let mut engine = Engine::new(); + for (path, policy, _, _) in declarations { + engine.add_policy(path.to_string(), policy.to_string())?; + } + for (path, _, selector, expected) in declarations { + anyhow::ensure!( + engine.has_declared_rule_rooted_at(path, selector)? == expected, + "unexpected dotted-prefix result for {path}" + ); + } + + engine.add_policy( + "other_module.rego".to_string(), + "package customer\nmetadata.parameters.child := true".to_string(), + )?; + engine.add_policy( + "selected_module.rego".to_string(), + "package customer\nallow := true".to_string(), + )?; + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("selected_module.rego", "metadata.parameters")?, + "declarations from another module in the same package must not match" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_preserves_params_behavior_and_excludes_references() -> Result<()> { + let params_declarations = [ + ("conditional.rego", "package customer\nparams if { false }"), + ("complete.rego", "package customer\nparams := false"), + ( + "descendant.rego", + "package customer\nparams.child.nested := null", + ), + ("default.rego", "package customer\ndefault params = false"), + ( + "default_child.rego", + "package customer\ndefault params.child = null", + ), + ( + "function.rego", + "package customer\nparams.lookup(value) := value", + ), + ("set.rego", "package customer\nparams contains \"item\""), + ]; + + let mut engine = Engine::new(); + for (path, policy) in params_declarations { + engine.add_policy(path.to_string(), policy.to_string())?; + anyhow::ensure!( + engine.has_declared_rule_rooted_at(path, "params")?, + "expected params declaration in {path}" + ); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at(path, "metadata")?, + "expected no metadata declaration in {path}" + ); + } + + engine.add_policy( + "references.rego".to_string(), + r#"package customer + import data.shared as metadata + # metadata.comment := true + note := "metadata.parameters" + config := {"metadata": true} + use := metadata.value + "# + .to_string(), + )?; + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("references.rego", "metadata")?, + "imports, references, comments, strings and object keys are not declarations" + ); + + engine.add_policy( + "same_package.rego".to_string(), + "package customer\nmetadata.child := true".to_string(), + )?; + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("references.rego", "metadata")?, + "same-package rules must not change another module's result" + ); + anyhow::ensure!( + engine.has_declared_rule_rooted_at("same_package.rego", "metadata")?, + "expected the selected same_package.rego declaration" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_validates_native_root_grammar_after_source_selection() -> Result<()> +{ + let mut engine = Engine::new(); + engine.add_policy( + "unique.rego".to_string(), + "package customer\nmetadata := true".to_string(), + )?; + + for invalid_root in [ + "", + " ", + "\tmetadata", + "metadata ", + "metadata # trailing comment", + ".metadata", + "metadata.", + "metadata..parameters", + "metadata .parameters", + "metadata. parameters", + "metadata[\"parameters\"]", + "metadata; other()", + "metadata := true", + "true", + "if", + "input", + "data", + ] { + let error = engine + .has_declared_rule_rooted_at("unique.rego", invalid_root) + .err() + .ok_or_else(|| anyhow::anyhow!("invalid root selector must fail"))?; + anyhow::ensure!( + is_invalid_root(&error), + "expected InvalidRuleRootError for {invalid_root:?}, got {error:#}" + ); + } + + for native_identifier in ["contains", "_", "__target__"] { + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("unique.rego", native_identifier)?, + "expected {native_identifier} to follow native rule-reference grammar" + ); + } + + let missing_source = engine + .has_declared_rule_rooted_at("missing.rego", "metadata..parameters") + .err() + .ok_or_else(|| anyhow::anyhow!("missing source must fail before selector validation"))?; + anyhow::ensure!( + !is_invalid_root(&missing_source), + "missing source must retain source-selection error precedence" + ); + + engine.add_policy( + "duplicate.rego".to_string(), + "package customer\nx := true".to_string(), + )?; + engine.add_policy( + "duplicate.rego".to_string(), + "package customer\nmetadata := true".to_string(), + )?; + let duplicate_source = engine + .has_declared_rule_rooted_at("duplicate.rego", "metadata..parameters") + .err() + .ok_or_else(|| anyhow::anyhow!("duplicate source must fail before selector validation"))?; + anyhow::ensure!( + !is_invalid_root(&duplicate_source), + "duplicate source must retain source-selection error precedence" + ); + + anyhow::ensure!( + Engine::new() + .add_policy("malformed.rego".to_string(), "package".to_string()) + .is_err(), + "malformed policy must fail to load" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_checks_every_head_even_after_a_match() -> Result<()> { + for (path, policy) in [ + ( + "unclassifiable_before.rego", + "package customer\nbroken[lookup()] := true\nmetadata := true", + ), + ( + "unclassifiable_after.rego", + "package customer\nmetadata := true\nbroken[lookup()] := true", + ), + ] { + let mut engine = Engine::new(); + engine.add_policy(path.to_string(), policy.to_string())?; + let error = engine + .has_declared_rule_rooted_at(path, "metadata") + .err() + .ok_or_else(|| { + anyhow::anyhow!("unclassifiable heads must fail even when another head matches") + })?; + anyhow::ensure!( + !is_invalid_root(&error), + "head-classification failure must not be classified as an invalid selector" + ); + } + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_classifies_unresolved_bracket_components() -> Result<()> { + let mut engine = Engine::new(); + engine.add_policy( + "dynamic.rego".to_string(), + "package customer\nmetadata[key] := true".to_string(), + )?; + anyhow::ensure!( + engine.has_declared_rule_rooted_at("dynamic.rego", "metadata")?, + "a bare root remains decidable before an unresolved bracket component" + ); + let dynamic_error = engine + .has_declared_rule_rooted_at("dynamic.rego", "metadata.parameters") + .err() + .ok_or_else(|| { + anyhow::anyhow!("an unresolved bracket component must not be treated as a field") + })?; + anyhow::ensure!( + !is_invalid_root(&dynamic_error), + "a dynamic head component is a classification error, not a selector error" + ); + + engine.add_policy( + "dynamic_after_mismatch.rego".to_string(), + "package customer\nmetadata.other[key] := true".to_string(), + )?; + anyhow::ensure!( + !engine + .has_declared_rule_rooted_at("dynamic_after_mismatch.rego", "metadata.parameters")?, + "a known sibling mismatch must be decided before a later dynamic component" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_checks_unclassifiable_heads_after_dotted_matches() -> Result<()> { + for (path, policy) in [ + ( + "dotted_unclassifiable_before.rego", + "package customer\nbroken[lookup()] := true\nmetadata.parameters := true", + ), + ( + "dotted_unclassifiable_after.rego", + "package customer\nmetadata.parameters := true\nbroken[lookup()] := true", + ), + ] { + let mut engine = Engine::new(); + engine.add_policy(path.to_string(), policy.to_string())?; + let error = engine + .has_declared_rule_rooted_at(path, "metadata.parameters") + .err() + .ok_or_else(|| { + anyhow::anyhow!("all rule heads must be classified around a dotted match") + })?; + anyhow::ensure!( + !is_invalid_root(&error), + "head-classification failure must not be classified as an invalid selector" + ); + } + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_uses_immutable_load_time_parser_context() -> Result<()> { + let mut engine = Engine::new(); + engine.set_rego_v0(true); + engine.add_policy( + "v0.rego".to_string(), + "package customer\nif := true".to_string(), + )?; + engine.add_policy( + "v0_dotted.rego".to_string(), + "package customer\nif.child := true".to_string(), + )?; + engine.set_rego_v0(false); + + anyhow::ensure!( + engine.has_declared_rule_rooted_at("v0.rego", "if")?, + "a later Rego mode change must not alter the loaded module's grammar" + ); + anyhow::ensure!( + engine.has_declared_rule_rooted_at("v0_dotted.rego", "if.child")?, + "dotted parsing must use the module's captured v0 rule-root grammar" + ); + + let clone = engine.clone(); + anyhow::ensure!( + clone.has_declared_rule_rooted_at("v0.rego", "if")?, + "cloned engines must share the immutable parser context" + ); + anyhow::ensure!( + clone.has_declared_rule_rooted_at("v0_dotted.rego", "if.child")?, + "cloned engines must preserve dotted selector parsing context" + ); + + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(1).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + anyhow::ensure!( + engine.has_declared_rule_rooted_at("v0.rego", "if")?, + "later policy length limits must not replace the load-time limits" + ); + + let mut imported = Engine::new(); + imported.set_rego_v0(true); + imported.add_policy( + "v0_future_keyword.rego".to_string(), + "package customer\nimport future.keywords.if\nallow := true".to_string(), + )?; + imported.set_rego_v0(false); + let error = imported + .has_declared_rule_rooted_at("v0_future_keyword.rego", "if") + .err() + .ok_or_else(|| anyhow::anyhow!("an imported future keyword is not a valid root"))?; + anyhow::ensure!( + is_invalid_root(&error), + "future-keyword membership must be captured independently of Module.rego_v1" + ); + + let mut imported_every = Engine::new(); + imported_every.set_rego_v0(true); + imported_every.add_policy( + "v0_every.rego".to_string(), + "package customer\nimport future.keywords.every\nallow := true".to_string(), + )?; + let imported_every_error = imported_every + .has_declared_rule_rooted_at("v0_every.rego", "in") + .err() + .ok_or_else(|| anyhow::anyhow!("importing every also makes in a future keyword"))?; + anyhow::ensure!( + is_invalid_root(&imported_every_error), + "imported in must be disallowed" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_enforces_captured_policy_file_size_limit() -> Result<()> { + const MAX_FILE_BYTES: usize = 32; + + let mut engine = Engine::new(); + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(64).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(MAX_FILE_BYTES) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(4) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + engine.add_policy( + "limits.rego".to_string(), + "package customer\nx := true".to_string(), + )?; + + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(1).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + + let below_limit = "a".repeat(31); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("limits.rego", &below_limit)?, + "a selector one byte below the captured file-size limit should be accepted" + ); + + let at_limit = "a".repeat(MAX_FILE_BYTES); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("limits.rego", &at_limit)?, + "a selector exactly at the captured file-size limit should be accepted" + ); + + let above_limit = "a".repeat(33); + let too_many_lines = "a\nb\nc\nd\ne"; + let source_errors = [ + ( + "file-size", + above_limit.as_str(), + Source::from_contents_with_limits( + "".to_string(), + above_limit.clone(), + NonZeroUsize::new(MAX_FILE_BYTES) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + NonZeroUsize::new(4) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + ) + .err() + .ok_or_else(|| anyhow::anyhow!("selector source should exceed the file-size limit"))?, + ), + ( + "line-count", + too_many_lines, + Source::from_contents_with_limits( + "".to_string(), + too_many_lines.to_string(), + NonZeroUsize::new(MAX_FILE_BYTES) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + NonZeroUsize::new(4) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + ) + .err() + .ok_or_else(|| anyhow::anyhow!("selector source should exceed the line-count limit"))?, + ), + ]; + for (limit, selector, expected_error) in source_errors { + let error = engine + .has_declared_rule_rooted_at("limits.rego", selector) + .err() + .ok_or_else(|| { + anyhow::anyhow!("a selector source over its captured limits must fail") + })?; + anyhow::ensure!( + !is_invalid_root(&error) && error.downcast_ref::().is_none(), + "{limit} source-construction failures must retain the operation-error category: {error:#}" + ); + anyhow::ensure!( + error.to_string() == expected_error.to_string(), + "{limit} source-construction error changed: expected {expected_error:#}, got {error:#}" + ); + } + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_enforces_captured_column_boundary() -> Result<()> { + let mut engine = Engine::new(); + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(32).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(2048) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(4) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + engine.add_policy( + "columns.rego".to_string(), + "package customer\nx := true".to_string(), + )?; + + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(1).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + + for length in [30, 31] { + let selector = "a".repeat(length); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("columns.rego", &selector)?, + "an absent identifier ending at or before column 32 should be accepted" + ); + } + + let selector = "a".repeat(32); + let error = engine + .has_declared_rule_rooted_at("columns.rego", &selector) + .err() + .ok_or_else(|| { + anyhow::anyhow!( + "an identifier advancing from column 1 to column 33 must exceed max_col 32" + ) + })?; + anyhow::ensure!( + is_invalid_root(&error), + "column-width parse errors should retain invalid-selector mapping: {error:#}" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_initializes_lexer_with_captured_column_limit() -> Result<()> { + let mut engine = Engine::new(); + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(2048) + .ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(2048) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(4) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + engine.add_policy( + "wide_columns.rego".to_string(), + "package customer\nx := true".to_string(), + )?; + + engine.set_policy_length_config(PolicyLengthConfig { + max_col: NonZeroU32::new(1).ok_or_else(|| anyhow::anyhow!("max_col must be non-zero"))?, + max_file_bytes: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_file_bytes must be non-zero"))?, + max_lines: NonZeroUsize::new(1) + .ok_or_else(|| anyhow::anyhow!("max_lines must be non-zero"))?, + }); + + let selector = "a".repeat(1024); + anyhow::ensure!( + !engine.has_declared_rule_rooted_at("wide_columns.rego", &selector)?, + "a 1024-character identifier should fit the captured 2048-column limit" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_preserves_target_effective_v1_without_future_keywords() -> Result<()> +{ + let mut engine = Engine::new(); + engine.set_rego_v0(true); + engine.add_policy( + "target_context.rego".to_string(), + r#"package customer +__target__ := "target.tests.sample_test_target" +if.child := true"# + .to_string(), + )?; + + engine.set_rego_v0(false); + engine.add_policy( + "ordinary_v1.rego".to_string(), + "package ordinary\nallow := true".to_string(), + )?; + + anyhow::ensure!( + engine.has_declared_rule_rooted_at("target_context.rego", "if.child")?, + "the __target__ directive enables v1 root restrictions without importing future keywords" + ); + for reserved_root in ["input", "data"] { + let error = engine + .has_declared_rule_rooted_at("target_context.rego", reserved_root) + .err() + .ok_or_else(|| { + anyhow::anyhow!("target modules must retain v1 input/data shadow restrictions") + })?; + anyhow::ensure!( + is_invalid_root(&error), + "expected v1 root restriction for {reserved_root}, got {error:#}" + ); + } + + let clone = engine.clone(); + anyhow::ensure!( + clone.has_declared_rule_rooted_at("target_context.rego", "if.child")?, + "clones must preserve the target module's actual future-keyword membership" + ); + + let ordinary_v1_error = engine + .has_declared_rule_rooted_at("ordinary_v1.rego", "if") + .err() + .ok_or_else(|| anyhow::anyhow!("an ordinary v1 module imports if as a future keyword"))?; + anyhow::ensure!( + is_invalid_root(&ordinary_v1_error), + "ordinary v1 parsing must continue to reject if as a root" + ); + + Ok(()) +} + +#[cfg(feature = "std")] +#[test] +fn has_declared_rule_rooted_at_captures_context_when_loading_from_file() -> Result<()> { + struct TempPolicyFile(std::path::PathBuf); + + impl Drop for TempPolicyFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + let path = std::env::temp_dir().join(format!( + "regorus-declared-rule-context-{}.rego", + std::process::id() + )); + std::fs::write(&path, "package customer\nif := true")?; + let _cleanup = TempPolicyFile(path.clone()); + let source_path = path.to_string_lossy().into_owned(); + + let mut engine = Engine::new(); + engine.set_rego_v0(true); + engine.add_policy_from_file(&path)?; + engine.set_rego_v0(false); + + anyhow::ensure!( + engine.has_declared_rule_rooted_at(&source_path, "if")?, + "file-loaded modules must retain their effective load-time parser context" + ); + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_context_survives_compiled_policy_restoration() -> Result<()> { + let mut engine = Engine::new(); + engine.set_rego_v0(true); + engine.add_policy( + "legacy.rego".to_string(), + "package legacy\nif := true\nallow := true".to_string(), + )?; + engine.add_policy( + "legacy_dotted.rego".to_string(), + "package legacy_dotted\nif.child := true".to_string(), + )?; + engine.add_policy( + "future.rego".to_string(), + "package guarded\nimport future.keywords.if\nactive := true".to_string(), + )?; + + let entrypoint = "data.legacy.allow".into(); + let compiled = engine.compile_with_entrypoint(&entrypoint)?; + let mut restored = Engine::new_from_compiled_policy(compiled.inner.clone()); + + anyhow::ensure!( + restored.has_declared_rule_rooted_at("legacy.rego", "if")?, + "compiled-policy restoration must preserve a v0 module's parser context" + ); + anyhow::ensure!( + restored.has_declared_rule_rooted_at("legacy_dotted.rego", "if.child")?, + "compiled-policy restoration must preserve dotted v0 selector context" + ); + let error = restored + .has_declared_rule_rooted_at("future.rego", "if") + .err() + .ok_or_else(|| { + anyhow::anyhow!("compiled-policy restoration must preserve imported keywords") + })?; + anyhow::ensure!(is_invalid_root(&error), "future keyword context was lost"); + + let interpreter_result = restored.eval_rule("data.legacy.allow".to_string())?; + let compiled_result = compiled.eval_with_input(Value::new_object())?; + anyhow::ensure!( + interpreter_result == Value::Bool(true) && compiled_result == interpreter_result, + "compiled-policy context transfer must not change interpreter evaluation" + ); + + #[cfg(feature = "rvm")] + { + let entry_points = ["data.legacy.allow"]; + let program = crate::languages::rego::compiler::Compiler::compile_from_policy( + &compiled, + &entry_points, + )?; + let mut vm = crate::rvm::vm::RegoVM::new_with_policy(compiled.clone()); + vm.load_program(program); + vm.set_data(Value::new_object())?; + vm.set_input(Value::new_object()); + let rvm_result = vm.execute_entry_point_by_name("data.legacy.allow")?; + anyhow::ensure!( + rvm_result == interpreter_result, + "compiled-policy context transfer must not change RVM evaluation" + ); + } + + Ok(()) +} + +#[test] +fn has_declared_rule_rooted_at_does_not_evaluate_rule_bodies() -> Result<()> { + let calls = Arc::new(AtomicUsize::new(0)); + let extension_calls = calls.clone(); + let mut engine = Engine::new(); + engine.add_extension( + "count".to_string(), + 0, + Box::new(move |_| { + extension_calls.fetch_add(1, Ordering::SeqCst); + Ok(Value::Bool(true)) + }), + )?; + engine.add_policy( + "extension.rego".to_string(), + "package customer\nmetadata.parameters := count()".to_string(), + )?; + + anyhow::ensure!( + engine.has_declared_rule_rooted_at("extension.rego", "metadata")?, + "expected metadata declaration" + ); + anyhow::ensure!( + engine.has_declared_rule_rooted_at("extension.rego", "metadata.parameters")?, + "expected the dotted metadata declaration without evaluating its body" + ); + anyhow::ensure!( + calls.load(Ordering::SeqCst) == 0, + "declaration detection must not invoke extensions" + ); + + Ok(()) +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index cecf9228..fca643ba 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod engine; pub mod interpreter; mod scheduler;