Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion bindings/csharp/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
170 changes: 146 additions & 24 deletions bindings/csharp/Regorus.Tests/RegorusTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<InvalidOperationException>(
() => 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<ArgumentException>(
() => 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<ArgumentException>(
() => 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<InvalidOperationException>(
() => 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()
{
Expand Down Expand Up @@ -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<ArgumentException>(
() => 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<InvalidOperationException>(
() => engine.HasDeclaredRuleRootedAt("duplicate.rego", "metadata..parameters"));
Assert.ThrowsException<InvalidOperationException>(
() => engine.HasDeclaredRuleRootedAt("missing.rego", "metadata..parameters"));
Assert.ThrowsException<ArgumentNullException>(
() => engine.HasDeclaredRuleRootedAt(null!, "metadata"));
Assert.ThrowsException<ArgumentNullException>(
() => engine.HasDeclaredRuleRootedAt("duplicate.rego", null!));
Assert.ThrowsException<ArgumentException>(
() => engine.HasDeclaredRuleRootedAt("duplicate.rego\0.rego", "metadata"));
Assert.ThrowsException<ArgumentException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => engine.HasDeclaredRuleRootedAt("unclassifiable.rego", "metadata"));
}

[TestMethod]
public void HasDeclaredRuleRootedAt_preserves_disposed_handle_behavior()
{
var engine = new Engine();
engine.Dispose();

Assert.ThrowsException<ObjectDisposedException>(
() => engine.HasDeclaredRuleRootedAt("a.rego", "metadata"));
}

[TestMethod]
public void Global_memory_limit_can_be_set_and_cleared()
{
Expand Down
42 changes: 42 additions & 0 deletions bindings/csharp/Regorus/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,54 @@ public void ClearPolicyLengthConfig()
});
}

/// <exception cref="ArgumentException">The path contains an embedded NUL.</exception>
public string? AddPolicy(string path, string rego)
{
Utf8Marshaller.ThrowIfContainsNul(path, nameof(path));

return Utf8Marshaller.WithUtf8(path, pathPtr =>
Utf8Marshaller.WithUtf8(rego, regoPtr =>
UseHandle(enginePtr =>
CheckAndDropResult(Regorus.Internal.API.regorus_engine_add_policy((Regorus.Internal.RegorusEngine*)enginePtr, (byte*)pathPtr, (byte*)regoPtr))
)));
}

/// <summary>
/// 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.
/// </summary>
/// <param name="sourcePath">The exact source label associated with the loaded policy module.</param>
/// <param name="rootName">A native-grammar rule path, either a root identifier such as <c>metadata</c> or a dotted path such as <c>metadata.parameters</c>.</param>
/// <returns>True if the module declares a rule at the selected path or any component-wise descendant.</returns>
/// <exception cref="ArgumentException">Either string contains an embedded NUL, or the rule path is invalid.</exception>
/// <exception cref="ArgumentNullException">The source path or root name is null.</exception>
/// <exception cref="InvalidOperationException">
/// No unique loaded module matches the source path or a rule head cannot be classified.
/// </exception>
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 =>
Expand All @@ -128,8 +167,11 @@ public void SetRegoV0(bool enable)
});
}

/// <exception cref="ArgumentException">The path contains an embedded NUL.</exception>
public string? AddPolicyFromFile(string path)
{
Utf8Marshaller.ThrowIfContainsNul(path, nameof(path));

return Utf8Marshaller.WithUtf8(path, pathPtr =>
{
return UseHandle(enginePtr =>
Expand Down
6 changes: 6 additions & 0 deletions bindings/csharp/Regorus/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Check whether the policy module at the exact source path declares a rule at rootName or a component-wise descendant.
/// </summary>
[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);

/// <summary>
/// Add a policy from file.
/// </summary>
Expand Down
6 changes: 5 additions & 1 deletion bindings/csharp/Regorus/ResultHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading