From ef1e8d8c00e5dd2636303ed72c2ecef19b792ffe Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Thu, 9 Apr 2026 14:54:23 +0200 Subject: [PATCH 01/11] FFI and C# bindings for RVM host-await builtins Expose registered host-await builtins, Program compilation, and RVM accessors through the FFI layer and C# bindings. FFI (bindings/ffi/src/rvm.rs): - compile_from_modules with host-await builtin registration - set/get host-await responses, argument, identifier - RegorusHostAwaitBuiltin C struct C# (bindings/csharp/Regorus/): - Program.CompileFromModules overloads with HostAwaitBuiltin[] - Rvm.SetHostAwaitResponses, GetHostAwaitArgument, GetHostAwaitIdentifier - HostAwaitBuiltin readonly struct, ExecutionMode enum - ModuleMarshalling: PinnedUtf8Strings, PinnedHostAwaitBuiltins Rust (src/rvm/vm/machine.rs): - get_host_await_argument() and get_host_await_identifier() accessors Docs: README examples, API.md reference, vm-runtime.md accessors Tests: suspendable + run-to-completion C# scenarios --- bindings/csharp/API.md | 100 +++++++ bindings/csharp/README.md | 78 ++++++ .../csharp/Regorus.Tests/RvmProgramTests.cs | 74 ++++++ bindings/csharp/Regorus/Compiler.cs | 33 +++ bindings/csharp/Regorus/ModuleMarshalling.cs | 92 ++++++- bindings/csharp/Regorus/NativeMethods.cs | 35 +++ bindings/csharp/Regorus/Program.cs | 102 +++++--- bindings/csharp/Regorus/Rvm.cs | 57 ++++ bindings/ffi/src/rvm.rs | 243 +++++++++++++++--- docs/rvm/vm-runtime.md | 6 + src/rvm/vm/machine.rs | 24 ++ 11 files changed, 769 insertions(+), 75 deletions(-) diff --git a/bindings/csharp/API.md b/bindings/csharp/API.md index a8b0c83fa..692665514 100644 --- a/bindings/csharp/API.md +++ b/bindings/csharp/API.md @@ -343,6 +343,106 @@ var tasks = Enumerable.Range(0, 100).Select(i => var results = await Task.WhenAll(tasks); ``` +## RVM Classes + +### Program + +`Program` represents a compiled RVM bytecode artifact. It can be created from +modules+entrypoints or from an `Engine`, serialized to binary, and loaded into +an `Rvm` for execution. + +```csharp +public sealed class Program : IDisposable +{ + // Compile from modules with entry points + public static Program CompileFromModules( + string dataJson, + IReadOnlyList modules, + IReadOnlyList entryPoints); + + // Compile with registered host-await builtins + public static Program CompileFromModules( + string dataJson, + IReadOnlyList modules, + IReadOnlyList entryPoints, + IReadOnlyList hostAwaitBuiltins); + + // Compile from an Engine instance + public static Program CompileFromEngine(Engine engine, IReadOnlyList entryPoints); + + // Serialize/deserialize + public byte[] SerializeBinary(); + public static Program DeserializeBinary(byte[] data, out bool isPartial); + + // Debug listing + public string GenerateListing(); + + public void Dispose(); +} +``` + +### Rvm + +`Rvm` is the virtual machine that executes a loaded `Program`. + +```csharp +public sealed class Rvm : IDisposable +{ + // Load a compiled program + public void LoadProgram(Program program); + + // Set data and input documents + public void SetDataJson(string dataJson); + public void SetInputJson(string inputJson); + + // Configure execution + public void SetExecutionMode(ExecutionMode mode); + + // Run + public string? Execute(); + public string? ExecuteEntryPoint(string entryPoint); + + // Suspendable-mode host-await interaction + public string? Resume(string valueJson); + public string? GetExecutionState(); + public string? GetHostAwaitIdentifier(); + public string? GetHostAwaitArgument(); + + // Run-to-completion-mode host-await pre-loading + public void SetHostAwaitResponses(string identifier, string[] valuesJson); + + public void Dispose(); +} +``` + +### HostAwaitBuiltin + +Declares a function name that the compiler should treat as a host-await call. +When the VM encounters a call to this function, it suspends (suspendable mode) +or consumes a pre-loaded response (run-to-completion mode). + +```csharp +public readonly struct HostAwaitBuiltin +{ + public string Name { get; } + public int ArgCount { get; } + + public HostAwaitBuiltin(string name, int argCount); +} +``` + +### ExecutionMode + +Controls how the VM handles host-await instructions. + +```csharp +public enum ExecutionMode +{ + RunToCompletion = 0, + Suspendable = 1, +} +``` + ## Performance Considerations ### Compilation Overhead diff --git a/bindings/csharp/README.md b/bindings/csharp/README.md index 0d5669bf7..f1e8c8a46 100644 --- a/bindings/csharp/README.md +++ b/bindings/csharp/README.md @@ -105,6 +105,84 @@ var result = vm.Execute(); Console.WriteLine($"allow: {result}"); ``` +## RVM with Registered Host-Await Builtins + +Host-await builtins let you register custom function names at compile time. +When the VM encounters a call to one of these functions, it suspends execution +so the host can resolve the call externally and resume with a value. + +### Suspendable mode (resolve one call at a time) + +```csharp +using Regorus; + +const string Policy = """ +package demo +import rego.v1 + +default allow := false + +allow if { + account := get_account({"id": input.account_id}) + account.status == "active" +} +"""; + +var modules = new[] { new PolicyModule("demo.rego", Policy) }; +var entryPoints = new[] { "data.demo.allow" }; +var builtins = new[] { new HostAwaitBuiltin("get_account", 1) }; + +using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins); +using var vm = new Rvm(); +vm.SetExecutionMode(ExecutionMode.Suspendable); +vm.LoadProgram(program); +vm.SetInputJson("""{"account_id": "acct-42"}"""); + +// First Execute suspends when get_account() is called +vm.Execute(); + +// Inspect which builtin suspended and what argument was passed +var identifier = vm.GetHostAwaitIdentifier(); // "get_account" +var argument = vm.GetHostAwaitArgument(); // {"id":"acct-42"} + +// Resolve externally, then resume +var result = vm.Resume("""{"status": "active", "name": "Alice"}"""); +Console.WriteLine($"allow: {result}"); // true +``` + +### Run-to-completion mode (pre-load responses) + +```csharp +using Regorus; + +const string Policy = """ +package demo +import rego.v1 + +default greeting := "unknown" + +greeting := msg if { + msg := translate(input.lang) +} +"""; + +var modules = new[] { new PolicyModule("demo.rego", Policy) }; +var entryPoints = new[] { "data.demo.greeting" }; +var builtins = new[] { new HostAwaitBuiltin("translate", 1) }; + +using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins); +using var vm = new Rvm(); +vm.SetExecutionMode(ExecutionMode.RunToCompletion); +vm.LoadProgram(program); +vm.SetInputJson("""{"lang": "es"}"""); + +// Queue responses before execution +vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" }); + +var result = vm.Execute(); +Console.WriteLine($"greeting: {result}"); // "hola" +``` + ## Azure RBAC Condition Evaluation Evaluate Azure RBAC condition expressions directly with a JSON evaluation context: diff --git a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs index ee65c6801..cf4d4aa97 100644 --- a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs +++ b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs @@ -116,4 +116,78 @@ public void Program_host_await_suspend_and_resume_succeeds() var resumed = vm.Resume("{\"tier\":\"gold\"}"); Assert.AreEqual("true", resumed, "expected allow=true after resume"); } + + private const string GetAccountPolicy = """ +package demo +import rego.v1 + +default allow := false + +allow if { + account := get_account({"id": input.account_id}) + account.status == "active" +} +"""; + + [TestMethod] + public void RegisteredHostAwait_Suspendable_SuspendAndResume() + { + var modules = new[] { new PolicyModule("account.rego", GetAccountPolicy) }; + var entryPoints = new[] { "data.demo.allow" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("get_account", 1) }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.Suspendable); + vm.LoadProgram(program); + vm.SetInputJson("{\"account_id\": \"acct-42\"}"); + + // Execute — should suspend on get_account() + vm.Execute(); + + // Verify we're suspended due to HostAwait with identifier "get_account" + var identifier = vm.GetHostAwaitIdentifier(); + Assert.AreEqual("\"get_account\"", identifier, "expected identifier to be get_account"); + + var argument = vm.GetHostAwaitArgument(); + Assert.IsNotNull(argument, "expected non-null argument"); + StringAssert.Contains(argument!, "acct-42", "expected account_id in argument"); + + // Resume with an account response + var result = vm.Resume("{\"status\": \"active\", \"name\": \"Alice\"}"); + Assert.AreEqual("true", result, "expected allow=true after resume"); + } + + private const string TranslatePolicy = """ +package demo +import rego.v1 + +default greeting := "unknown" + +greeting := msg if { + msg := translate(input.lang) +} +"""; + + [TestMethod] + public void RegisteredHostAwait_RunToCompletion_WithPreloadedResponses() + { + var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate", 1) }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.RunToCompletion); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + + // Pre-load a response for translate + vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" }); + + // Execute — translate returns "hola" + var result = vm.Execute(); + Assert.AreEqual("\"hola\"", result, "expected greeting=hola"); + } + } \ No newline at end of file diff --git a/bindings/csharp/Regorus/Compiler.cs b/bindings/csharp/Regorus/Compiler.cs index ea036104e..a7b1b5714 100644 --- a/bindings/csharp/Regorus/Compiler.cs +++ b/bindings/csharp/Regorus/Compiler.cs @@ -36,6 +36,39 @@ public PolicyModule(string id, string content) } } + /// + /// Represents a host-awaitable builtin registration for RVM compilation. + /// When registered, calls to the named function compile to HostAwait instructions + /// rather than regular function calls. + /// + /// + /// Host-await builtins are only supported via the CompileFromModules path. + /// The CompileFromEngine path does not support host-await registration. + /// + public readonly struct HostAwaitBuiltin + { + /// + /// Gets the function name. + /// + public string Name { get; } + + /// + /// Gets the expected argument count. + /// + public int ArgCount { get; } + + /// + /// Initializes a new instance of the HostAwaitBuiltin struct. + /// + /// The function name to register as host-awaitable. + /// The expected number of arguments. + public HostAwaitBuiltin(string name, int argCount) + { + Name = name; + ArgCount = argCount; + } + } + /// /// Provides static methods for compiling policies into efficient compiled representations. /// These are convenience methods that create an engine internally and perform compilation. diff --git a/bindings/csharp/Regorus/ModuleMarshalling.cs b/bindings/csharp/Regorus/ModuleMarshalling.cs index 2a7983e16..78922ed1a 100644 --- a/bindings/csharp/Regorus/ModuleMarshalling.cs +++ b/bindings/csharp/Regorus/ModuleMarshalling.cs @@ -45,12 +45,12 @@ public void Dispose() } } - internal sealed class PinnedEntryPoints : IDisposable + internal sealed class PinnedUtf8Strings : IDisposable { private readonly List _pins; private bool _disposed; - internal PinnedEntryPoints(IntPtr[] buffer, int length, List pins) + internal PinnedUtf8Strings(IntPtr[] buffer, int length, List pins) { Buffer = buffer; Length = length; @@ -119,14 +119,14 @@ internal static PinnedPolicyModules PinPolicyModules(IReadOnlyList } } - internal static PinnedEntryPoints PinEntryPoints(IReadOnlyList entryPoints) + internal static PinnedUtf8Strings PinUtf8Strings(IReadOnlyList values) { - if (entryPoints is null) + if (values is null) { - throw new ArgumentNullException(nameof(entryPoints)); + throw new ArgumentNullException(nameof(values)); } - var count = entryPoints.Count; + var count = values.Count; var buffer = ArrayPool.Shared.Rent(count); var pins = new List(count); @@ -134,12 +134,12 @@ internal static PinnedEntryPoints PinEntryPoints(IReadOnlyList entryPoin { for (int i = 0; i < count; i++) { - var entryPinned = Utf8Marshaller.Pin(entryPoints[i]); - pins.Add(entryPinned); - buffer[i] = (IntPtr)entryPinned.Pointer; + var pinned = Utf8Marshaller.Pin(values[i]); + pins.Add(pinned); + buffer[i] = (IntPtr)pinned.Pointer; } - return new PinnedEntryPoints(buffer, count, pins); + return new PinnedUtf8Strings(buffer, count, pins); } catch { @@ -152,5 +152,77 @@ internal static PinnedEntryPoints PinEntryPoints(IReadOnlyList entryPoin throw; } } + + internal sealed class PinnedHostAwaitBuiltins : IDisposable + { + private readonly List _pins; + private bool _disposed; + + internal PinnedHostAwaitBuiltins(RegorusHostAwaitBuiltin[] buffer, int length, List pins) + { + Buffer = buffer; + Length = length; + _pins = pins; + } + + internal RegorusHostAwaitBuiltin[] Buffer { get; } + + internal int Length { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var pin in _pins) + { + pin.Dispose(); + } + + ArrayPool.Shared.Return(Buffer, clearArray: true); + _disposed = true; + } + } + + internal static PinnedHostAwaitBuiltins PinHostAwaitBuiltins(IReadOnlyList builtins) + { + if (builtins is null) + { + throw new ArgumentNullException(nameof(builtins)); + } + + var count = builtins.Count; + var buffer = ArrayPool.Shared.Rent(count); + var pins = new List(count); + + try + { + for (int i = 0; i < count; i++) + { + var namePinned = Utf8Marshaller.Pin(builtins[i].Name); + pins.Add(namePinned); + + buffer[i] = new RegorusHostAwaitBuiltin + { + name = namePinned.Pointer, + arg_count = (UIntPtr)builtins[i].ArgCount, + }; + } + + return new PinnedHostAwaitBuiltins(buffer, count, pins); + } + catch + { + foreach (var pin in pins) + { + pin.Dispose(); + } + + ArrayPool.Shared.Return(buffer, clearArray: true); + throw; + } + } } } diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs index 327d5b15e..8ac9eee45 100644 --- a/bindings/csharp/Regorus/NativeMethods.cs +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -245,6 +245,31 @@ internal static unsafe partial class API /// [DllImport(LibraryName, EntryPoint = "regorus_rvm_set_execution_timer_config", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern RegorusResult regorus_rvm_set_execution_timer_config(RegorusRvm* vm, [MarshalAs(UnmanagedType.I1)] bool has_config, RegorusExecutionTimerConfig config); + + /// + /// Pre-load HostAwait responses for run-to-completion mode. + /// + [DllImport(LibraryName, EntryPoint = "regorus_rvm_set_host_await_responses", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_rvm_set_host_await_responses(RegorusRvm* vm, byte* identifier, byte** values_json, UIntPtr values_len); + + /// + /// Get the HostAwait argument as a JSON string. + /// + [DllImport(LibraryName, EntryPoint = "regorus_rvm_get_host_await_argument", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_rvm_get_host_await_argument(RegorusRvm* vm); + + /// + /// Get the HostAwait identifier as a JSON string. + /// + [DllImport(LibraryName, EntryPoint = "regorus_rvm_get_host_await_identifier", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_rvm_get_host_await_identifier(RegorusRvm* vm); + + /// + /// Compile an RVM program from data/modules/entry-points with registered host-awaitable builtins. + /// + [DllImport(LibraryName, EntryPoint = "regorus_program_compile_from_modules_with_host_await", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_program_compile_from_modules_with_host_await(byte* data_json, RegorusPolicyModule* modules, UIntPtr modules_len, byte** entry_points, UIntPtr entry_points_len, RegorusHostAwaitBuiltin* host_await_builtins, UIntPtr host_await_builtins_len); + /// Add a policy. /// The policy is parsed into AST. /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_policy @@ -973,5 +998,15 @@ internal unsafe partial struct RegorusAliasRegistry { } + /// + /// FFI wrapper for HostAwaitBuiltin struct. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusHostAwaitBuiltin + { + public byte* name; + public UIntPtr arg_count; + } + #endregion } diff --git a/bindings/csharp/Regorus/Program.cs b/bindings/csharp/Regorus/Program.cs index 264480750..06d2a01bd 100644 --- a/bindings/csharp/Regorus/Program.cs +++ b/bindings/csharp/Regorus/Program.cs @@ -51,9 +51,34 @@ public static Program CompileFromModules(string dataJson, IEnumerable public static Program CompileFromModules(string dataJson, IReadOnlyList modules, IReadOnlyList entryPoints) { - if (modules is null) + return CompileFromModulesInner(dataJson, modules, entryPoints, hostAwaitBuiltins: null); + } + + /// + /// Compile an RVM program from an engine instance and entry points. + /// + public static Program CompileFromEngine(Engine engine, IEnumerable entryPoints) + { + if (engine is null) { - throw new ArgumentNullException(nameof(modules)); + throw new ArgumentNullException(nameof(engine)); + } + if (entryPoints is null) + { + throw new ArgumentNullException(nameof(entryPoints)); + } + + return CompileFromEngine(engine, entryPoints.ToArray()); + } + + /// + /// Compile an RVM program from an engine instance and entry points. + /// + public static Program CompileFromEngine(Engine engine, IReadOnlyList entryPoints) + { + if (engine is null) + { + throw new ArgumentNullException(nameof(engine)); } if (entryPoints is null) @@ -66,18 +91,14 @@ public static Program CompileFromModules(string dataJson, IReadOnlyList + return engine.UseHandleForInterop(enginePtr => { - fixed (RegorusPolicyModule* modulesPtr = pinnedModules.Buffer) fixed (IntPtr* entryPtr = pinnedEntryPoints.Buffer) { - var result = API.regorus_program_compile_from_modules( - (byte*)dataPtr, - modulesPtr, - (UIntPtr)pinnedModules.Length, + var result = API.regorus_engine_compile_program_with_entrypoints( + (RegorusEngine*)enginePtr, (byte**)entryPtr, (UIntPtr)pinnedEntryPoints.Length); @@ -87,30 +108,23 @@ public static Program CompileFromModules(string dataJson, IReadOnlyList - /// Compile an RVM program from an engine instance and entry points. + /// Compile an RVM program from modules, entry points, and registered host-awaitable builtins. /// - public static Program CompileFromEngine(Engine engine, IEnumerable entryPoints) + public static Program CompileFromModules(string dataJson, IReadOnlyList modules, IReadOnlyList entryPoints, IReadOnlyList hostAwaitBuiltins) { - if (engine is null) - { - throw new ArgumentNullException(nameof(engine)); - } - if (entryPoints is null) + if (hostAwaitBuiltins is null) { - throw new ArgumentNullException(nameof(entryPoints)); + throw new ArgumentNullException(nameof(hostAwaitBuiltins)); } - return CompileFromEngine(engine, entryPoints.ToArray()); + return CompileFromModulesInner(dataJson, modules, entryPoints, hostAwaitBuiltins); } - /// - /// Compile an RVM program from an engine instance and entry points. - /// - public static Program CompileFromEngine(Engine engine, IReadOnlyList entryPoints) + private static Program CompileFromModulesInner(string dataJson, IReadOnlyList modules, IReadOnlyList entryPoints, IReadOnlyList? hostAwaitBuiltins) { - if (engine is null) + if (modules is null) { - throw new ArgumentNullException(nameof(engine)); + throw new ArgumentNullException(nameof(modules)); } if (entryPoints is null) @@ -123,18 +137,42 @@ public static Program CompileFromEngine(Engine engine, IReadOnlyList ent throw new ArgumentException("At least one entry point is required.", nameof(entryPoints)); } - using var pinnedEntryPoints = ModuleMarshalling.PinEntryPoints(entryPoints); + using var pinnedModules = ModuleMarshalling.PinPolicyModules(modules); + using var pinnedEntryPoints = ModuleMarshalling.PinUtf8Strings(entryPoints); - return engine.UseHandleForInterop(enginePtr => + return Utf8Marshaller.WithUtf8(dataJson, dataPtr => { + fixed (RegorusPolicyModule* modulesPtr = pinnedModules.Buffer) fixed (IntPtr* entryPtr = pinnedEntryPoints.Buffer) { - var result = API.regorus_engine_compile_program_with_entrypoints( - (RegorusEngine*)enginePtr, - (byte**)entryPtr, - (UIntPtr)pinnedEntryPoints.Length); + if (hostAwaitBuiltins is { Count: > 0 }) + { + using var pinnedBuiltins = ModuleMarshalling.PinHostAwaitBuiltins(hostAwaitBuiltins); + fixed (RegorusHostAwaitBuiltin* builtinsPtr = pinnedBuiltins.Buffer) + { + var result = API.regorus_program_compile_from_modules_with_host_await( + (byte*)dataPtr, + modulesPtr, + (UIntPtr)pinnedModules.Length, + (byte**)entryPtr, + (UIntPtr)pinnedEntryPoints.Length, + builtinsPtr, + (UIntPtr)pinnedBuiltins.Length); - return GetProgramResult(result); + return GetProgramResult(result); + } + } + + { + var result = API.regorus_program_compile_from_modules( + (byte*)dataPtr, + modulesPtr, + (UIntPtr)pinnedModules.Length, + (byte**)entryPtr, + (UIntPtr)pinnedEntryPoints.Length); + + return GetProgramResult(result); + } } }); } diff --git a/bindings/csharp/Regorus/Rvm.cs b/bindings/csharp/Regorus/Rvm.cs index 45c2fda3c..e7c163350 100644 --- a/bindings/csharp/Regorus/Rvm.cs +++ b/bindings/csharp/Regorus/Rvm.cs @@ -213,6 +213,63 @@ public void SetExecutionMode(ExecutionMode mode) }); } + /// + /// Get the HostAwait argument as a JSON string. + /// Returns null if the VM is not in a HostAwait-suspended state. + /// + public string? GetHostAwaitArgument() + { + return UseHandle(vmPtr => + { + return CheckAndDropResult(API.regorus_rvm_get_host_await_argument((RegorusRvm*)vmPtr)); + }); + } + + /// + /// Get the HostAwait identifier as a JSON string. + /// Returns null if the VM is not in a HostAwait-suspended state. + /// + public string? GetHostAwaitIdentifier() + { + return UseHandle(vmPtr => + { + return CheckAndDropResult(API.regorus_rvm_get_host_await_identifier((RegorusRvm*)vmPtr)); + }); + } + + /// + /// Pre-load HostAwait responses for run-to-completion mode. + /// Clears any previously configured responses, then queues the + /// provided values for the given identifier. + /// + /// The builtin identifier. + /// Array of JSON strings to queue as responses. + public void SetHostAwaitResponses(string identifier, string[] valuesJson) + { + if (valuesJson is null) + { + throw new ArgumentNullException(nameof(valuesJson)); + } + + using var pinnedValues = ModuleMarshalling.PinUtf8Strings(valuesJson); + + Utf8Marshaller.WithUtf8(identifier, idPtr => + { + UseHandle(vmPtr => + { + fixed (IntPtr* arrPtr = pinnedValues.Buffer) + { + CheckAndDropResult(API.regorus_rvm_set_host_await_responses( + (RegorusRvm*)vmPtr, + (byte*)idPtr, + (byte**)arrPtr, + (UIntPtr)pinnedValues.Length)); + } + return 0; + }); + }); + } + private static Rvm GetRvmResult(RegorusResult result) { try diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index 7603305c1..6f0b2477f 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -123,6 +123,54 @@ pub extern "C" fn regorus_program_compile_from_policy( }) } +/// Shared implementation for compiling an RVM program from data/modules/entry-points +/// with optional host-await builtins. +fn compile_from_modules_inner( + data_json: *const c_char, + modules: *const RegorusPolicyModule, + modules_len: usize, + entry_points: *const *const c_char, + entry_points_len: usize, + ha_builtins: Option<&[(&str, usize)]>, +) -> Result<*mut RegorusProgram> { + if entry_points_len == 0 { + return Err(anyhow!("entry_points must contain at least one entry")); + } + + let data_str = from_c_str(data_json)?; + let data = Value::from_json_str(&data_str)?; + let policy_modules = convert_c_modules_to_rust(modules, modules_len)?; + let entry_points_vec = convert_c_entry_points(entry_points, entry_points_len)?; + let entry_points_ref: Vec<&str> = entry_points_vec.iter().map(|s| s.as_str()).collect(); + + let entry_rule = entry_points_ref + .first() + .ok_or_else(|| anyhow!("entry_points must contain at least one entry"))?; + + let compiled_policy = + regorus::compile_policy_with_entrypoint(data, &policy_modules, (*entry_rule).into())?; + + let program = match ha_builtins { + Some(builtins) => Compiler::compile_from_policy_with_host_await( + &compiled_policy, + &entry_points_ref, + builtins, + )?, + None => Compiler::compile_from_policy(&compiled_policy, &entry_points_ref)?, + }; + Ok(Box::into_raw(Box::new(RegorusProgram { program }))) +} + +fn compile_from_modules_result(output: Result<*mut RegorusProgram>) -> RegorusResult { + match output { + Ok(program) => RegorusResult::ok_pointer(program as *mut c_void), + Err(err) => RegorusResult::err_with_message( + RegorusStatus::CompilationFailed, + format!("RVM compilation failed: {err}"), + ), + } +} + /// Compile an RVM program from data/modules and entry points. /// /// * `data_json` - JSON string containing static data for policy evaluation @@ -139,39 +187,14 @@ pub extern "C" fn regorus_program_compile_from_modules( entry_points_len: usize, ) -> RegorusResult { with_unwind_guard(|| { - let output = || -> Result<*mut RegorusProgram> { - if entry_points_len == 0 { - return Err(anyhow!("entry_points must contain at least one entry")); - } - - let data_str = from_c_str(data_json)?; - let data = Value::from_json_str(&data_str)?; - let policy_modules = convert_c_modules_to_rust(modules, modules_len)?; - - let entry_points_vec = convert_c_entry_points(entry_points, entry_points_len)?; - let entry_points_ref: Vec<&str> = entry_points_vec.iter().map(|s| s.as_str()).collect(); - - let entry_rule = entry_points_ref - .first() - .ok_or_else(|| anyhow!("entry_points must contain at least one entry"))?; - - let compiled_policy = regorus::compile_policy_with_entrypoint( - data, - &policy_modules, - (*entry_rule).into(), - )?; - - let program = Compiler::compile_from_policy(&compiled_policy, &entry_points_ref)?; - Ok(Box::into_raw(Box::new(RegorusProgram { program }))) - }(); - - match output { - Ok(program) => RegorusResult::ok_pointer(program as *mut c_void), - Err(err) => RegorusResult::err_with_message( - RegorusStatus::CompilationFailed, - format!("RVM compilation failed: {err}"), - ), - } + compile_from_modules_result(compile_from_modules_inner( + data_json, + modules, + modules_len, + entry_points, + entry_points_len, + None, + )) }) } @@ -640,3 +663,157 @@ fn convert_c_modules_to_rust( Ok(policy_modules) } + +/// A registered host-awaitable builtin passed via FFI. +#[repr(C)] +pub struct RegorusHostAwaitBuiltin { + /// Null-terminated UTF-8 builtin name. + pub name: *const c_char, + /// Expected number of arguments. + pub arg_count: usize, +} + +/// Compile an RVM program from data/modules and entry points, with registered +/// host-awaitable builtins. +/// +/// * `data_json` - JSON string containing static data for policy evaluation +/// * `modules` / `modules_len` - Policy modules to compile +/// * `entry_points` / `entry_points_len` - Entry point rule paths +/// * `host_await_builtins` / `host_await_builtins_len` - Builtins that compile to HostAwait +#[no_mangle] +pub extern "C" fn regorus_program_compile_from_modules_with_host_await( + data_json: *const c_char, + modules: *const RegorusPolicyModule, + modules_len: usize, + entry_points: *const *const c_char, + entry_points_len: usize, + host_await_builtins: *const RegorusHostAwaitBuiltin, + host_await_builtins_len: usize, +) -> RegorusResult { + with_unwind_guard(|| { + let output = || -> Result<*mut RegorusProgram> { + let ha_builtins = + convert_c_host_await_builtins(host_await_builtins, host_await_builtins_len)?; + let ha_ref: Vec<(&str, usize)> = + ha_builtins.iter().map(|(n, a)| (n.as_str(), *a)).collect(); + compile_from_modules_inner( + data_json, + modules, + modules_len, + entry_points, + entry_points_len, + Some(&ha_ref), + ) + }(); + compile_from_modules_result(output) + }) +} + +/// Pre-load HostAwait responses for run-to-completion mode. +/// +/// Clears any previously configured responses, then queues the +/// provided values for the given identifier. +/// +/// * `vm` - RVM instance +/// * `identifier` - Null-terminated UTF-8 identifier +/// * `values_json` - Array of null-terminated UTF-8 JSON response strings +/// * `values_len` - Number of responses +#[no_mangle] +pub extern "C" fn regorus_rvm_set_host_await_responses( + vm: *mut RegorusRvm, + identifier: *const c_char, + values_json: *const *const c_char, + values_len: usize, +) -> RegorusResult { + with_unwind_guard(|| { + to_regorus_result(|| -> Result<()> { + let vm = to_ref(vm)?; + let mut guard = vm.try_write()?; + let id_str = from_c_str(identifier)?; + let id_value = Value::String(id_str.into()); + + let mut values = alloc::collections::VecDeque::with_capacity(values_len); + for i in 0..values_len { + unsafe { + if values_json.is_null() { + return Err(anyhow!("null values_json pointer")); + } + let ptr = *values_json.add(i); + let json_str = from_c_str(ptr)?; + let val = Value::from_json_str(&json_str)?; + values.push_back(val); + } + } + + guard.set_host_await_responses(core::iter::once((id_value, values))); + Ok(()) + }()) + }) +} + +/// Get the HostAwait argument as a JSON string. +/// +/// Returns the argument value if the VM is suspended due to a HostAwait instruction, +/// or None if the VM is not in a HostAwait-suspended state. +#[no_mangle] +pub extern "C" fn regorus_rvm_get_host_await_argument(vm: *mut RegorusRvm) -> RegorusResult { + with_unwind_guard(|| { + let output = || -> Result> { + let vm = to_ref(vm)?; + let guard = vm.try_read()?; + match guard.get_host_await_argument() { + Some(arg) => Ok(Some(arg.to_json_str()?)), + None => Ok(None), + } + }(); + + match output { + Ok(Some(json)) => RegorusResult::ok_string(json), + Ok(None) => RegorusResult::ok_void(), + Err(err) => RegorusResult::err_with_message(RegorusStatus::Error, err.to_string()), + } + }) +} + +/// Get the HostAwait identifier as a JSON string. +/// +/// Returns the identifier value if the VM is suspended due to a HostAwait instruction, +/// or None if the VM is not in a HostAwait-suspended state. +#[no_mangle] +pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> RegorusResult { + with_unwind_guard(|| { + let output = || -> Result> { + let vm = to_ref(vm)?; + let guard = vm.try_read()?; + match guard.get_host_await_identifier() { + Some(id) => Ok(Some(id.to_json_str()?)), + None => Ok(None), + } + }(); + + match output { + Ok(Some(json)) => RegorusResult::ok_string(json), + Ok(None) => RegorusResult::ok_void(), + Err(err) => RegorusResult::err_with_message(RegorusStatus::Error, err.to_string()), + } + }) +} + +pub fn convert_c_host_await_builtins( + builtins: *const RegorusHostAwaitBuiltin, + len: usize, +) -> Result> { + if builtins.is_null() && len > 0 { + return Err(anyhow!("null host_await_builtins pointer")); + } + let mut result = Vec::with_capacity(len); + for i in 0..len { + unsafe { + let b = &*builtins.add(i); + let name = from_c_str(b.name) + .map_err(|e| anyhow!("invalid host-await builtin name at index {i}: {e}"))?; + result.push((name, b.arg_count)); + } + } + Ok(result) +} diff --git a/docs/rvm/vm-runtime.md b/docs/rvm/vm-runtime.md index b7ec55803..9352c0150 100644 --- a/docs/rvm/vm-runtime.md +++ b/docs/rvm/vm-runtime.md @@ -53,6 +53,12 @@ fields to responsibilities. `VmError::ArithmeticError` and returning `Value::Undefined`. - Accessors (`get_pc`, `get_registers`, `get_loop_stack`, etc.) aid debugging and visualisation tooling. +- `get_host_await_argument`: when the VM is suspended on a `HostAwait`, returns + the argument value passed by the policy. Returns `None` if not suspended or + suspended for a different reason. +- `get_host_await_identifier`: when the VM is suspended on a `HostAwait`, returns + the identifier (function name) that triggered the suspension. Returns `None` + if not applicable. --- diff --git a/src/rvm/vm/machine.rs b/src/rvm/vm/machine.rs index 48a078607..59f6d6fbd 100644 --- a/src/rvm/vm/machine.rs +++ b/src/rvm/vm/machine.rs @@ -493,6 +493,30 @@ impl RegoVM { } } + /// Get the HostAwait argument if the VM is suspended due to a HostAwait instruction. + /// Returns `None` if the VM is not in a HostAwait-suspended state. + pub const fn get_host_await_argument(&self) -> Option<&Value> { + match self.execution_state { + ExecutionState::Suspended { + reason: SuspendReason::HostAwait { ref argument, .. }, + .. + } => Some(argument), + _ => None, + } + } + + /// Get the HostAwait identifier if the VM is suspended due to a HostAwait instruction. + /// Returns `None` if the VM is not in a HostAwait-suspended state. + pub const fn get_host_await_identifier(&self) -> Option<&Value> { + match self.execution_state { + ExecutionState::Suspended { + reason: SuspendReason::HostAwait { ref identifier, .. }, + .. + } => Some(identifier), + _ => None, + } + } + #[inline] #[allow(dead_code)] pub(super) fn get_register(&self, index: u8) -> Result<&Value> { From 2a933bcfa925fd172596c3715aa00cf6194f62fc Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Mon, 29 Jun 2026 00:42:21 +0200 Subject: [PATCH 02/11] refine FFI/C# host-await bindings per review - HostAwaitBuiltin: single-arg (name) ctor; arg_count fixed to 1 - SetHostAwaitResponses: multi-identifier dictionary API - always route CompileFromModules through host-await FFI - dead-check/IIFE cleanup, pub(crate), expanded tests + docs --- bindings/csharp/API.md | 14 +- bindings/csharp/README.md | 13 +- .../csharp/Regorus.Tests/RvmProgramTests.cs | 161 +++++++++++++++++- bindings/csharp/Regorus/Compiler.cs | 22 ++- bindings/csharp/Regorus/ModuleMarshalling.cs | 146 +++++++++++++++- bindings/csharp/Regorus/NativeMethods.cs | 14 +- bindings/csharp/Regorus/Program.cs | 48 ++---- bindings/csharp/Regorus/Rvm.cs | 43 ++--- bindings/ffi/src/rvm.rs | 147 ++++++++++------ 9 files changed, 476 insertions(+), 132 deletions(-) diff --git a/bindings/csharp/API.md b/bindings/csharp/API.md index 692665514..4bffa9ebb 100644 --- a/bindings/csharp/API.md +++ b/bindings/csharp/API.md @@ -408,8 +408,11 @@ public sealed class Rvm : IDisposable public string? GetHostAwaitIdentifier(); public string? GetHostAwaitArgument(); - // Run-to-completion-mode host-await pre-loading - public void SetHostAwaitResponses(string identifier, string[] valuesJson); + // Run-to-completion-mode host-await pre-loading. Atomically replaces all + // previously configured responses for every identifier; pass every + // identifier the policy may invoke in a single call. + public void SetHostAwaitResponses( + IReadOnlyDictionary> responsesByIdentifier); public void Dispose(); } @@ -421,13 +424,16 @@ Declares a function name that the compiler should treat as a host-await call. When the VM encounters a call to this function, it suspends (suspendable mode) or consumes a pre-loaded response (run-to-completion mode). +Registered builtins are restricted to exactly one argument at the compiler +level (use object packing to pass multiple values), so the C# struct does not +expose an `argCount` parameter. + ```csharp public readonly struct HostAwaitBuiltin { public string Name { get; } - public int ArgCount { get; } - public HostAwaitBuiltin(string name, int argCount); + public HostAwaitBuiltin(string name); } ``` diff --git a/bindings/csharp/README.md b/bindings/csharp/README.md index f1e8c8a46..37ddea276 100644 --- a/bindings/csharp/README.md +++ b/bindings/csharp/README.md @@ -130,7 +130,7 @@ allow if { var modules = new[] { new PolicyModule("demo.rego", Policy) }; var entryPoints = new[] { "data.demo.allow" }; -var builtins = new[] { new HostAwaitBuiltin("get_account", 1) }; +var builtins = new[] { new HostAwaitBuiltin("get_account") }; using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins); using var vm = new Rvm(); @@ -168,7 +168,7 @@ greeting := msg if { var modules = new[] { new PolicyModule("demo.rego", Policy) }; var entryPoints = new[] { "data.demo.greeting" }; -var builtins = new[] { new HostAwaitBuiltin("translate", 1) }; +var builtins = new[] { new HostAwaitBuiltin("translate") }; using var program = Program.CompileFromModules("{}", modules, entryPoints, builtins); using var vm = new Rvm(); @@ -176,8 +176,13 @@ vm.SetExecutionMode(ExecutionMode.RunToCompletion); vm.LoadProgram(program); vm.SetInputJson("""{"lang": "es"}"""); -// Queue responses before execution -vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" }); +// Queue responses before execution. SetHostAwaitResponses atomically replaces +// ALL prior responses for every identifier — pass every identifier the policy +// may invoke in a single call. +vm.SetHostAwaitResponses(new Dictionary> +{ + ["translate"] = new[] { "\"hola\"" }, +}); var result = vm.Execute(); Console.WriteLine($"greeting: {result}"); // "hola" diff --git a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs index cf4d4aa97..4e6dd4e70 100644 --- a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs +++ b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Regorus.Tests; @@ -134,7 +135,7 @@ public void RegisteredHostAwait_Suspendable_SuspendAndResume() { var modules = new[] { new PolicyModule("account.rego", GetAccountPolicy) }; var entryPoints = new[] { "data.demo.allow" }; - var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("get_account", 1) }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("get_account") }; using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); using var vm = new Rvm(); @@ -145,7 +146,9 @@ public void RegisteredHostAwait_Suspendable_SuspendAndResume() // Execute — should suspend on get_account() vm.Execute(); - // Verify we're suspended due to HostAwait with identifier "get_account" + // Verify we're suspended due to HostAwait with identifier "get_account". + // GetHostAwaitIdentifier returns the JSON-encoded Value, so the identifier + // string itself includes the surrounding JSON quotes. var identifier = vm.GetHostAwaitIdentifier(); Assert.AreEqual("\"get_account\"", identifier, "expected identifier to be get_account"); @@ -174,7 +177,7 @@ public void RegisteredHostAwait_RunToCompletion_WithPreloadedResponses() { var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; var entryPoints = new[] { "data.demo.greeting" }; - var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate", 1) }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); using var vm = new Rvm(); @@ -183,11 +186,161 @@ public void RegisteredHostAwait_RunToCompletion_WithPreloadedResponses() vm.SetInputJson("{\"lang\": \"es\"}"); // Pre-load a response for translate - vm.SetHostAwaitResponses("translate", new[] { "\"hola\"" }); + vm.SetHostAwaitResponses(new Dictionary> + { + ["translate"] = new[] { "\"hola\"" }, + }); // Execute — translate returns "hola" var result = vm.Execute(); Assert.AreEqual("\"hola\"", result, "expected greeting=hola"); } + [TestMethod] + public void RegisteredHostAwait_CompileRejectsEmptyOrWhitespaceName() + { + var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") }; + var entryPoints = new[] { "data.demo.allow" }; + + foreach (var badName in new[] { "", " ", "\t" }) + { + var builtins = new[] { new HostAwaitBuiltin(badName) }; + Assert.ThrowsException( + () => Program.CompileFromModules("{}", modules, entryPoints, builtins), + $"expected compilation to reject empty/whitespace name '{badName}'"); + } + } + + [TestMethod] + public void RegisteredHostAwait_CompileRejectsDuplicateRegistration() + { + var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") }; + var entryPoints = new[] { "data.demo.allow" }; + var builtins = new[] + { + new HostAwaitBuiltin("translate"), + new HostAwaitBuiltin("translate"), + }; + + Assert.ThrowsException( + () => Program.CompileFromModules("{}", modules, entryPoints, builtins), + "expected compilation to reject duplicate registration"); + } + + [TestMethod] + public void RegisteredHostAwait_CompileRejectsReservedName() + { + var modules = new[] { new PolicyModule("noop.rego", "package demo\nallow := true\n") }; + var entryPoints = new[] { "data.demo.allow" }; + var builtins = new[] { new HostAwaitBuiltin("__builtin_host_await") }; + + Assert.ThrowsException( + () => Program.CompileFromModules("{}", modules, entryPoints, builtins), + "expected compilation to reject reserved __builtin_host_await identifier"); + } + + [TestMethod] + public void RegisteredHostAwait_GetAccessorsReturnNullWhenVmIsNotSuspended() + { + var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.RunToCompletion); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + vm.SetHostAwaitResponses(new Dictionary> + { + ["translate"] = new[] { "\"hola\"" }, + }); + vm.Execute(); + + // After run-to-completion completes successfully, the VM is no longer suspended. + Assert.IsNull(vm.GetHostAwaitArgument(), "expected null argument when VM is not suspended"); + Assert.IsNull(vm.GetHostAwaitIdentifier(), "expected null identifier when VM is not suspended"); + } + + private const string TranslateNoDefaultPolicy = """ +package demo +import rego.v1 + +# No default — if translate() can't produce a value, the entry point +# evaluation propagates the error to the caller. +result := translate(input.lang) +"""; + + [TestMethod] + public void RegisteredHostAwait_RunToCompletion_FailsWhenResponseQueueExhausted() + { + var modules = new[] { new PolicyModule("translate.rego", TranslateNoDefaultPolicy) }; + var entryPoints = new[] { "data.demo.result" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.RunToCompletion); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + + // No responses pre-loaded — translate has nothing to return. + // Document the actual behavior: in run-to-completion mode the + // missing-response error fails the rule body silently rather than + // surfacing as an exception, so Execute() returns the literal + // string `""` for an entry point that produced no value. + // Asserting the exact return value locks this contract so any + // future change (e.g. propagating an exception) shows up as a + // test failure that has to be explicitly re-acknowledged. + var actual = vm.Execute(); + Assert.AreEqual( + "\"\"", + actual, + "expected `\"\"` when the response queue is exhausted"); + } + + private const string MultiAwaitPolicy = """ +package demo +import rego.v1 + +default greeting := "unknown" + +greeting := combined if { + hello := translate(input.lang) + user := lookup_user({"id": input.user_id}) + combined := sprintf("%s %s", [hello, user.name]) +} +"""; + + [TestMethod] + public void RegisteredHostAwait_RunToCompletion_MultipleIdentifiersInSingleCall() + { + var modules = new[] { new PolicyModule("multi.rego", MultiAwaitPolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] + { + new HostAwaitBuiltin("translate"), + new HostAwaitBuiltin("lookup_user"), + }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.RunToCompletion); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\", \"user_id\": \"u1\"}"); + + // Pre-load responses for BOTH identifiers in a single call. + // The new IReadOnlyDictionary API atomically replaces ALL prior + // responses, so this single call must carry every identifier the + // policy may invoke during this run. + vm.SetHostAwaitResponses(new Dictionary> + { + ["translate"] = new[] { "\"hola\"" }, + ["lookup_user"] = new[] { "{\"name\": \"Alice\"}" }, + }); + + var result = vm.Execute(); + Assert.AreEqual("\"hola Alice\"", result, "expected combined greeting from both responses"); + } + } \ No newline at end of file diff --git a/bindings/csharp/Regorus/Compiler.cs b/bindings/csharp/Regorus/Compiler.cs index a7b1b5714..4462e77f7 100644 --- a/bindings/csharp/Regorus/Compiler.cs +++ b/bindings/csharp/Regorus/Compiler.cs @@ -42,30 +42,28 @@ public PolicyModule(string id, string content) /// rather than regular function calls. /// /// - /// Host-await builtins are only supported via the CompileFromModules path. - /// The CompileFromEngine path does not support host-await registration. + /// Registered builtins are restricted to exactly one argument at the compiler + /// level (use object packing to pass multiple values). The argument count is + /// therefore not exposed here. + /// + /// Host-await builtins are not yet supported via the CompileFromEngine + /// path; only CompileFromModules accepts them today. /// public readonly struct HostAwaitBuiltin { /// - /// Gets the function name. + /// Gets the function name to register as host-awaitable. /// public string Name { get; } - /// - /// Gets the expected argument count. - /// - public int ArgCount { get; } - /// /// Initializes a new instance of the HostAwaitBuiltin struct. /// /// The function name to register as host-awaitable. - /// The expected number of arguments. - public HostAwaitBuiltin(string name, int argCount) + /// Thrown when is null. + public HostAwaitBuiltin(string name) { - Name = name; - ArgCount = argCount; + Name = name ?? throw new ArgumentNullException(nameof(name)); } } diff --git a/bindings/csharp/Regorus/ModuleMarshalling.cs b/bindings/csharp/Regorus/ModuleMarshalling.cs index 78922ed1a..e22236c92 100644 --- a/bindings/csharp/Regorus/ModuleMarshalling.cs +++ b/bindings/csharp/Regorus/ModuleMarshalling.cs @@ -4,6 +4,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Runtime.InteropServices; using Regorus; #nullable enable @@ -207,7 +208,6 @@ internal static PinnedHostAwaitBuiltins PinHostAwaitBuiltins(IReadOnlyList _pins; + private readonly List _innerBuffers; + private readonly List _innerHandles; + private bool _disposed; + + internal PinnedHostAwaitResponseSets( + RegorusHostAwaitResponseSet[] buffer, + int length, + List pins, + List innerBuffers, + List innerHandles) + { + Buffer = buffer; + Length = length; + _pins = pins; + _innerBuffers = innerBuffers; + _innerHandles = innerHandles; + } + + internal RegorusHostAwaitResponseSet[] Buffer { get; } + + internal int Length { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var pin in _pins) + { + pin.Dispose(); + } + + foreach (var handle in _innerHandles) + { + if (handle.IsAllocated) + { + handle.Free(); + } + } + + foreach (var inner in _innerBuffers) + { + ArrayPool.Shared.Return(inner, clearArray: true); + } + + ArrayPool.Shared.Return(Buffer, clearArray: true); + _disposed = true; + } + } + + // Each inner per-identifier `IntPtr[]` is rented from the pool and + // pinned via GCHandle so the FFI can hold raw `byte**` pointers into + // it for the duration of the call. The PinnedHostAwaitResponseSets + // owner releases the handles, returns the buffers to the pool, and + // disposes all UTF-8 pins. + internal static PinnedHostAwaitResponseSets PinHostAwaitResponseSets( + IReadOnlyDictionary> responsesByIdentifier) + { + if (responsesByIdentifier is null) + { + throw new ArgumentNullException(nameof(responsesByIdentifier)); + } + + var count = responsesByIdentifier.Count; + var buffer = ArrayPool.Shared.Rent(count); + var pins = new List(count); + var innerBuffers = new List(count); + var innerHandles = new List(count); + + try + { + int idx = 0; + foreach (var kvp in responsesByIdentifier) + { + if (kvp.Value is null) + { + throw new ArgumentException( + $"values for identifier '{kvp.Key}' must not be null", + nameof(responsesByIdentifier)); + } + + var idPinned = Utf8Marshaller.Pin(kvp.Key); + pins.Add(idPinned); + + var valueCount = kvp.Value.Count; + var innerBuffer = ArrayPool.Shared.Rent(Math.Max(valueCount, 1)); + innerBuffers.Add(innerBuffer); + + for (int j = 0; j < valueCount; j++) + { + var valuePinned = Utf8Marshaller.Pin(kvp.Value[j]); + pins.Add(valuePinned); + innerBuffer[j] = (IntPtr)valuePinned.Pointer; + } + + var handle = GCHandle.Alloc(innerBuffer, GCHandleType.Pinned); + innerHandles.Add(handle); + + buffer[idx] = new RegorusHostAwaitResponseSet + { + identifier = idPinned.Pointer, + values_json = (byte**)handle.AddrOfPinnedObject(), + values_len = (UIntPtr)valueCount, + }; + idx++; + } + + return new PinnedHostAwaitResponseSets( + buffer, + count, + pins, + innerBuffers, + innerHandles); + } + catch + { + foreach (var pin in pins) + { + pin.Dispose(); + } + + foreach (var handle in innerHandles) + { + if (handle.IsAllocated) + { + handle.Free(); + } + } + + foreach (var inner in innerBuffers) + { + ArrayPool.Shared.Return(inner, clearArray: true); + } + + ArrayPool.Shared.Return(buffer, clearArray: true); + throw; + } + } } } diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs index 8ac9eee45..50f72e634 100644 --- a/bindings/csharp/Regorus/NativeMethods.cs +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -250,7 +250,7 @@ internal static unsafe partial class API /// Pre-load HostAwait responses for run-to-completion mode. /// [DllImport(LibraryName, EntryPoint = "regorus_rvm_set_host_await_responses", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_rvm_set_host_await_responses(RegorusRvm* vm, byte* identifier, byte** values_json, UIntPtr values_len); + internal static extern RegorusResult regorus_rvm_set_host_await_responses(RegorusRvm* vm, RegorusHostAwaitResponseSet* response_sets, UIntPtr response_sets_len); /// /// Get the HostAwait argument as a JSON string. @@ -1005,7 +1005,17 @@ internal unsafe partial struct RegorusAliasRegistry internal unsafe partial struct RegorusHostAwaitBuiltin { public byte* name; - public UIntPtr arg_count; + } + + /// + /// FFI wrapper for a per-identifier set of pre-loaded HostAwait response values. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusHostAwaitResponseSet + { + public byte* identifier; + public byte** values_json; + public UIntPtr values_len; } #endregion diff --git a/bindings/csharp/Regorus/Program.cs b/bindings/csharp/Regorus/Program.cs index 06d2a01bd..80d228e95 100644 --- a/bindings/csharp/Regorus/Program.cs +++ b/bindings/csharp/Regorus/Program.cs @@ -52,9 +52,7 @@ public static Program CompileFromModules(string dataJson, IEnumerable modules, IReadOnlyList entryPoints) { return CompileFromModulesInner(dataJson, modules, entryPoints, hostAwaitBuiltins: null); - } - - /// + } /// /// Compile an RVM program from an engine instance and entry points. /// public static Program CompileFromEngine(Engine engine, IEnumerable entryPoints) @@ -139,40 +137,28 @@ private static Program CompileFromModulesInner(string dataJson, IReadOnlyList()); return Utf8Marshaller.WithUtf8(dataJson, dataPtr => { fixed (RegorusPolicyModule* modulesPtr = pinnedModules.Buffer) fixed (IntPtr* entryPtr = pinnedEntryPoints.Buffer) + fixed (RegorusHostAwaitBuiltin* builtinsPtr = pinnedBuiltins.Buffer) { - if (hostAwaitBuiltins is { Count: > 0 }) - { - using var pinnedBuiltins = ModuleMarshalling.PinHostAwaitBuiltins(hostAwaitBuiltins); - fixed (RegorusHostAwaitBuiltin* builtinsPtr = pinnedBuiltins.Buffer) - { - var result = API.regorus_program_compile_from_modules_with_host_await( - (byte*)dataPtr, - modulesPtr, - (UIntPtr)pinnedModules.Length, - (byte**)entryPtr, - (UIntPtr)pinnedEntryPoints.Length, - builtinsPtr, - (UIntPtr)pinnedBuiltins.Length); - - return GetProgramResult(result); - } - } - - { - var result = API.regorus_program_compile_from_modules( - (byte*)dataPtr, - modulesPtr, - (UIntPtr)pinnedModules.Length, - (byte**)entryPtr, - (UIntPtr)pinnedEntryPoints.Length); - - return GetProgramResult(result); - } + var result = API.regorus_program_compile_from_modules_with_host_await( + (byte*)dataPtr, + modulesPtr, + (UIntPtr)pinnedModules.Length, + (byte**)entryPtr, + (UIntPtr)pinnedEntryPoints.Length, + builtinsPtr, + (UIntPtr)pinnedBuiltins.Length); + + return GetProgramResult(result); } }); } diff --git a/bindings/csharp/Regorus/Rvm.cs b/bindings/csharp/Regorus/Rvm.cs index e7c163350..93598709c 100644 --- a/bindings/csharp/Regorus/Rvm.cs +++ b/bindings/csharp/Regorus/Rvm.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using Regorus.Internal; #nullable enable @@ -239,34 +240,36 @@ public void SetExecutionMode(ExecutionMode mode) /// /// Pre-load HostAwait responses for run-to-completion mode. - /// Clears any previously configured responses, then queues the - /// provided values for the given identifier. /// - /// The builtin identifier. - /// Array of JSON strings to queue as responses. - public void SetHostAwaitResponses(string identifier, string[] valuesJson) + /// + /// Atomically replaces all previously configured responses for every + /// identifier. Pass all identifiers the policy may invoke in a single + /// call; calling this method again discards the prior configuration + /// in full. + /// + /// + /// Per-identifier queues of JSON-encoded response values, consumed in + /// FIFO order when the corresponding host-await builtin is invoked. + /// + public void SetHostAwaitResponses(IReadOnlyDictionary> responsesByIdentifier) { - if (valuesJson is null) + if (responsesByIdentifier is null) { - throw new ArgumentNullException(nameof(valuesJson)); + throw new ArgumentNullException(nameof(responsesByIdentifier)); } - using var pinnedValues = ModuleMarshalling.PinUtf8Strings(valuesJson); + using var pinnedSets = ModuleMarshalling.PinHostAwaitResponseSets(responsesByIdentifier); - Utf8Marshaller.WithUtf8(identifier, idPtr => + UseHandle(vmPtr => { - UseHandle(vmPtr => + fixed (RegorusHostAwaitResponseSet* setsPtr = pinnedSets.Buffer) { - fixed (IntPtr* arrPtr = pinnedValues.Buffer) - { - CheckAndDropResult(API.regorus_rvm_set_host_await_responses( - (RegorusRvm*)vmPtr, - (byte*)idPtr, - (byte**)arrPtr, - (UIntPtr)pinnedValues.Length)); - } - return 0; - }); + CheckAndDropResult(API.regorus_rvm_set_host_await_responses( + (RegorusRvm*)vmPtr, + setsPtr, + (UIntPtr)pinnedSets.Length)); + } + return 0; }); } diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index 6f0b2477f..25c7b5655 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -131,7 +131,8 @@ fn compile_from_modules_inner( modules_len: usize, entry_points: *const *const c_char, entry_points_len: usize, - ha_builtins: Option<&[(&str, usize)]>, + host_await_builtins: *const RegorusHostAwaitBuiltin, + host_await_builtins_len: usize, ) -> Result<*mut RegorusProgram> { if entry_points_len == 0 { return Err(anyhow!("entry_points must contain at least one entry")); @@ -143,21 +144,25 @@ fn compile_from_modules_inner( let entry_points_vec = convert_c_entry_points(entry_points, entry_points_len)?; let entry_points_ref: Vec<&str> = entry_points_vec.iter().map(|s| s.as_str()).collect(); - let entry_rule = entry_points_ref - .first() - .ok_or_else(|| anyhow!("entry_points must contain at least one entry"))?; + // Safe: early-return above guarantees entry_points_len > 0, and + // convert_c_entry_points preserves length, so the slice is non-empty. + let entry_rule = entry_points_ref[0]; let compiled_policy = - regorus::compile_policy_with_entrypoint(data, &policy_modules, (*entry_rule).into())?; - - let program = match ha_builtins { - Some(builtins) => Compiler::compile_from_policy_with_host_await( - &compiled_policy, - &entry_points_ref, - builtins, - )?, - None => Compiler::compile_from_policy(&compiled_policy, &entry_points_ref)?, - }; + regorus::compile_policy_with_entrypoint(data, &policy_modules, entry_rule.into())?; + + // `Compiler::compile_from_policy_with_host_await` with an empty builtins + // slice is equivalent to `compile_from_policy`, so both FFI entry points + // route through this single path. A null `host_await_builtins` pointer + // with `len == 0` is the canonical "no builtins" shape. + let ha_builtins = convert_c_host_await_builtins(host_await_builtins, host_await_builtins_len)?; + let ha_ref: Vec<(&str, usize)> = ha_builtins.iter().map(|(n, a)| (n.as_str(), *a)).collect(); + + let program = Compiler::compile_from_policy_with_host_await( + &compiled_policy, + &entry_points_ref, + &ha_ref, + )?; Ok(Box::into_raw(Box::new(RegorusProgram { program }))) } @@ -193,7 +198,8 @@ pub extern "C" fn regorus_program_compile_from_modules( modules_len, entry_points, entry_points_len, - None, + core::ptr::null(), + 0, )) }) } @@ -665,12 +671,16 @@ fn convert_c_modules_to_rust( } /// A registered host-awaitable builtin passed via FFI. +/// +/// The argument count is currently fixed to 1 by the compiler (see +/// `Compiler::register_host_await_builtin`), so it is not exposed at the +/// FFI boundary. The struct exists as a stable layout to allow future +/// expansion (e.g. an explicit `arg_count` field) without breaking ABI +/// when callers pin a fixed-size array of these. #[repr(C)] pub struct RegorusHostAwaitBuiltin { /// Null-terminated UTF-8 builtin name. pub name: *const c_char, - /// Expected number of arguments. - pub arg_count: usize, } /// Compile an RVM program from data/modules and entry points, with registered @@ -691,61 +701,88 @@ pub extern "C" fn regorus_program_compile_from_modules_with_host_await( host_await_builtins_len: usize, ) -> RegorusResult { with_unwind_guard(|| { - let output = || -> Result<*mut RegorusProgram> { - let ha_builtins = - convert_c_host_await_builtins(host_await_builtins, host_await_builtins_len)?; - let ha_ref: Vec<(&str, usize)> = - ha_builtins.iter().map(|(n, a)| (n.as_str(), *a)).collect(); - compile_from_modules_inner( - data_json, - modules, - modules_len, - entry_points, - entry_points_len, - Some(&ha_ref), - ) - }(); - compile_from_modules_result(output) + compile_from_modules_result(compile_from_modules_inner( + data_json, + modules, + modules_len, + entry_points, + entry_points_len, + host_await_builtins, + host_await_builtins_len, + )) }) } +/// A set of pre-loaded HostAwait response values for a single identifier, +/// passed via FFI to [`regorus_rvm_set_host_await_responses`]. +#[repr(C)] +pub struct RegorusHostAwaitResponseSet { + /// Null-terminated UTF-8 identifier of the host-await builtin. + pub identifier: *const c_char, + /// Array of null-terminated UTF-8 JSON response strings. + pub values_json: *const *const c_char, + /// Number of responses in `values_json`. + pub values_len: usize, +} + /// Pre-load HostAwait responses for run-to-completion mode. /// -/// Clears any previously configured responses, then queues the -/// provided values for the given identifier. +/// Atomically replaces all previously configured responses for **every** +/// identifier with the supplied per-identifier queues. Pass all identifiers +/// the policy may invoke in a single call; calling this function again +/// discards the prior configuration in full. /// /// * `vm` - RVM instance -/// * `identifier` - Null-terminated UTF-8 identifier -/// * `values_json` - Array of null-terminated UTF-8 JSON response strings -/// * `values_len` - Number of responses +/// * `response_sets` - Array of per-identifier response sets +/// * `response_sets_len` - Number of entries in `response_sets` #[no_mangle] pub extern "C" fn regorus_rvm_set_host_await_responses( vm: *mut RegorusRvm, - identifier: *const c_char, - values_json: *const *const c_char, - values_len: usize, + response_sets: *const RegorusHostAwaitResponseSet, + response_sets_len: usize, ) -> RegorusResult { with_unwind_guard(|| { to_regorus_result(|| -> Result<()> { let vm = to_ref(vm)?; let mut guard = vm.try_write()?; - let id_str = from_c_str(identifier)?; - let id_value = Value::String(id_str.into()); - let mut values = alloc::collections::VecDeque::with_capacity(values_len); - for i in 0..values_len { - unsafe { - if values_json.is_null() { - return Err(anyhow!("null values_json pointer")); - } - let ptr = *values_json.add(i); - let json_str = from_c_str(ptr)?; - let val = Value::from_json_str(&json_str)?; + if response_sets.is_null() && response_sets_len > 0 { + return Err(anyhow!("null response_sets pointer")); + } + + let mut all = Vec::with_capacity(response_sets_len); + for i in 0..response_sets_len { + // SAFETY: caller guarantees `response_sets` points to a + // contiguous array of `response_sets_len` `RegorusHostAwaitResponseSet` + // values, and the inner pointers reference valid C strings. + let set = unsafe { &*response_sets.add(i) }; + + let id_str = from_c_str(set.identifier) + .map_err(|e| anyhow!("invalid identifier in response set at index {i}: {e}"))?; + let id_value = Value::String(id_str.into()); + + if set.values_json.is_null() && set.values_len > 0 { + return Err(anyhow!( + "null values_json pointer in response set at index {i}" + )); + } + + let mut values = alloc::collections::VecDeque::with_capacity(set.values_len); + for j in 0..set.values_len { + let ptr = unsafe { *set.values_json.add(j) }; + let json_str = from_c_str(ptr).map_err(|e| { + anyhow!("invalid JSON pointer at response_sets[{i}].values_json[{j}]: {e}") + })?; + let val = Value::from_json_str(&json_str).map_err(|e| { + anyhow!("invalid JSON at response_sets[{i}].values_json[{j}]: {e}") + })?; values.push_back(val); } + + all.push((id_value, values)); } - guard.set_host_await_responses(core::iter::once((id_value, values))); + guard.set_host_await_responses(all); Ok(()) }()) }) @@ -799,7 +836,7 @@ pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> }) } -pub fn convert_c_host_await_builtins( +pub(crate) fn convert_c_host_await_builtins( builtins: *const RegorusHostAwaitBuiltin, len: usize, ) -> Result> { @@ -812,7 +849,9 @@ pub fn convert_c_host_await_builtins( let b = &*builtins.add(i); let name = from_c_str(b.name) .map_err(|e| anyhow!("invalid host-await builtin name at index {i}: {e}"))?; - result.push((name, b.arg_count)); + // Arg count is fixed to 1 by the compiler — see the doc comment + // on `RegorusHostAwaitBuiltin` and `Compiler::register_host_await_builtin`. + result.push((name, 1)); } } Ok(result) From 68a760b0f99417c0d19a89bc9a80d13c4a83e6ea Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Thu, 2 Jul 2026 22:00:06 +0200 Subject: [PATCH 03/11] fix(ffi): use shared ref + lock for host-await entry points PR #672 review: the host-await FFI functions acquired the VM via `to_ref`, which fabricates an exclusive `&mut RegorusRvm` before the lock is taken. Concurrent foreign callers could each materialize that `&mut` to the same handle, aliasing before `try_read`/`try_write` arbitrates. Switch `set_host_await_responses`, `get_host_await_argument`, and `get_host_await_identifier` to `to_shared_ref` + lock, matching the other RVM entry points in this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/ffi/src/rvm.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index 25c7b5655..fb982dead 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -743,7 +743,7 @@ pub extern "C" fn regorus_rvm_set_host_await_responses( ) -> RegorusResult { with_unwind_guard(|| { to_regorus_result(|| -> Result<()> { - let vm = to_ref(vm)?; + let vm = to_shared_ref(vm as *const RegorusRvm)?; let mut guard = vm.try_write()?; if response_sets.is_null() && response_sets_len > 0 { @@ -796,7 +796,7 @@ pub extern "C" fn regorus_rvm_set_host_await_responses( pub extern "C" fn regorus_rvm_get_host_await_argument(vm: *mut RegorusRvm) -> RegorusResult { with_unwind_guard(|| { let output = || -> Result> { - let vm = to_ref(vm)?; + let vm = to_shared_ref(vm as *const RegorusRvm)?; let guard = vm.try_read()?; match guard.get_host_await_argument() { Some(arg) => Ok(Some(arg.to_json_str()?)), @@ -820,7 +820,7 @@ pub extern "C" fn regorus_rvm_get_host_await_argument(vm: *mut RegorusRvm) -> Re pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> RegorusResult { with_unwind_guard(|| { let output = || -> Result> { - let vm = to_ref(vm)?; + let vm = to_shared_ref(vm as *const RegorusRvm)?; let guard = vm.try_read()?; match guard.get_host_await_identifier() { Some(id) => Ok(Some(id.to_json_str()?)), From c853adde77a46558009000cbb98d60194dab884e Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Thu, 2 Jul 2026 22:25:35 +0200 Subject: [PATCH 04/11] fix(ffi): use try_reserve for caller-controlled host-await allocations The three host-await entry points allocated Vec/VecDeque with capacities taken directly from caller-supplied lengths (response_sets_len, values_len, builtins len). A hostile or buggy caller passing an absurd length would trigger a capacity-overflow panic inside with_unwind_guard, flipping the process-global poison flag and permanently disabling every engine instance in the process. Switch these three sites to fallible reservation (try_reserve) so an over-large request becomes a clean RegorusStatus error instead of a panic. Only the three new host-await sites are converted here; the pre-existing with_capacity sites elsewhere are left as a follow-up. This introduces the try_reserve pattern to the repo (not previously used); enforce_limit() is not applicable at the FFI boundary since it bounds cumulative evaluation allocation, not a single caller-controlled reservation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/ffi/src/rvm.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index fb982dead..f02f45bc4 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -750,7 +750,12 @@ pub extern "C" fn regorus_rvm_set_host_await_responses( return Err(anyhow!("null response_sets pointer")); } - let mut all = Vec::with_capacity(response_sets_len); + let mut all = Vec::new(); + all.try_reserve(response_sets_len).map_err(|_| { + anyhow!( + "failed to reserve capacity for {response_sets_len} host-await response sets" + ) + })?; for i in 0..response_sets_len { // SAFETY: caller guarantees `response_sets` points to a // contiguous array of `response_sets_len` `RegorusHostAwaitResponseSet` @@ -767,7 +772,13 @@ pub extern "C" fn regorus_rvm_set_host_await_responses( )); } - let mut values = alloc::collections::VecDeque::with_capacity(set.values_len); + let mut values = alloc::collections::VecDeque::new(); + values.try_reserve(set.values_len).map_err(|_| { + anyhow!( + "failed to reserve capacity for {} response values at index {i}", + set.values_len + ) + })?; for j in 0..set.values_len { let ptr = unsafe { *set.values_json.add(j) }; let json_str = from_c_str(ptr).map_err(|e| { @@ -843,7 +854,10 @@ pub(crate) fn convert_c_host_await_builtins( if builtins.is_null() && len > 0 { return Err(anyhow!("null host_await_builtins pointer")); } - let mut result = Vec::with_capacity(len); + let mut result = Vec::new(); + result + .try_reserve(len) + .map_err(|_| anyhow!("failed to reserve capacity for {len} host-await builtins"))?; for i in 0..len { unsafe { let b = &*builtins.add(i); From 8093b0f62ceb46eea5a7b11c32e750f7b10f1eca Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Thu, 2 Jul 2026 22:49:32 +0200 Subject: [PATCH 05/11] fix(ffi): return raw host-await identifier string, not JSON regorus_rvm_get_host_await_identifier serialized the identifier via to_json_str(), so a string identifier came back JSON-quoted (e.g. "get_account" with literal surrounding quotes). But regorus_rvm_set_host_await_responses consumes the identifier as a raw C string and wraps it in Value::String. The core matches identifiers by exact Value equality, so the getter's quoted output did not round-trip back into the setter. Return the raw identifier string instead, symmetric with the setter, and reject non-string identifiers loudly (identifiers are always strings per the compiler). Update the C# wrapper docs and the round-trip test to expect the unquoted form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/csharp/Regorus.Tests/RvmProgramTests.cs | 4 +--- bindings/csharp/Regorus/NativeMethods.cs | 2 +- bindings/csharp/Regorus/Rvm.cs | 2 +- bindings/ffi/src/rvm.rs | 9 ++++++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs index 4e6dd4e70..fb025aed8 100644 --- a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs +++ b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs @@ -147,10 +147,8 @@ public void RegisteredHostAwait_Suspendable_SuspendAndResume() vm.Execute(); // Verify we're suspended due to HostAwait with identifier "get_account". - // GetHostAwaitIdentifier returns the JSON-encoded Value, so the identifier - // string itself includes the surrounding JSON quotes. var identifier = vm.GetHostAwaitIdentifier(); - Assert.AreEqual("\"get_account\"", identifier, "expected identifier to be get_account"); + Assert.AreEqual("get_account", identifier, "expected identifier to be get_account"); var argument = vm.GetHostAwaitArgument(); Assert.IsNotNull(argument, "expected non-null argument"); diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs index 50f72e634..da9d7f636 100644 --- a/bindings/csharp/Regorus/NativeMethods.cs +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -259,7 +259,7 @@ internal static unsafe partial class API internal static extern RegorusResult regorus_rvm_get_host_await_argument(RegorusRvm* vm); /// - /// Get the HostAwait identifier as a JSON string. + /// Get the HostAwait identifier as a raw UTF-8 string. /// [DllImport(LibraryName, EntryPoint = "regorus_rvm_get_host_await_identifier", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern RegorusResult regorus_rvm_get_host_await_identifier(RegorusRvm* vm); diff --git a/bindings/csharp/Regorus/Rvm.cs b/bindings/csharp/Regorus/Rvm.cs index 93598709c..39228ca74 100644 --- a/bindings/csharp/Regorus/Rvm.cs +++ b/bindings/csharp/Regorus/Rvm.cs @@ -227,7 +227,7 @@ public void SetExecutionMode(ExecutionMode mode) } /// - /// Get the HostAwait identifier as a JSON string. + /// Get the HostAwait identifier as a raw UTF-8 string (not JSON-quoted). /// Returns null if the VM is not in a HostAwait-suspended state. /// public string? GetHostAwaitIdentifier() diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index f02f45bc4..e6cfb45b5 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -823,8 +823,10 @@ pub extern "C" fn regorus_rvm_get_host_await_argument(vm: *mut RegorusRvm) -> Re }) } -/// Get the HostAwait identifier as a JSON string. +/// Get the HostAwait identifier as a raw UTF-8 string. /// +/// The returned string is the identifier itself (not JSON-quoted), so it can be +/// passed directly as an identifier to `regorus_rvm_set_host_await_responses`. /// Returns the identifier value if the VM is suspended due to a HostAwait instruction, /// or None if the VM is not in a HostAwait-suspended state. #[no_mangle] @@ -834,13 +836,14 @@ pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> let vm = to_shared_ref(vm as *const RegorusRvm)?; let guard = vm.try_read()?; match guard.get_host_await_identifier() { - Some(id) => Ok(Some(id.to_json_str()?)), + Some(Value::String(s)) => Ok(Some(s.as_ref().to_string())), + Some(_) => Err(anyhow!("host-await identifier must be a string")), None => Ok(None), } }(); match output { - Ok(Some(json)) => RegorusResult::ok_string(json), + Ok(Some(identifier)) => RegorusResult::ok_string(identifier), Ok(None) => RegorusResult::ok_void(), Err(err) => RegorusResult::err_with_message(RegorusStatus::Error, err.to_string()), } From ed3332f0bcac15c9a6fb390833ad3653fcbcecd0 Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Fri, 3 Jul 2026 00:16:26 +0200 Subject: [PATCH 06/11] docs(rvm): document host-await argument JSON encoding convention The host-await argument getter serializes the policy-supplied value with the engine's canonical Value->JSON encoding, identical to evaluation results. Note this in vm-runtime.md so hosts know the Rego-only variants are encoded (a Set as an array, Undefined as the string "") rather than preserved, since JSON cannot round-trip them back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/rvm/vm-runtime.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/rvm/vm-runtime.md b/docs/rvm/vm-runtime.md index 9352c0150..85c9cb4f0 100644 --- a/docs/rvm/vm-runtime.md +++ b/docs/rvm/vm-runtime.md @@ -55,7 +55,8 @@ fields to responsibilities. and visualisation tooling. - `get_host_await_argument`: when the VM is suspended on a `HostAwait`, returns the argument value passed by the policy. Returns `None` if not suspended or - suspended for a different reason. + suspended for a different reason. At the serialization boundary the argument + uses the same `Value`→JSON encoding as evaluation results. - `get_host_await_identifier`: when the VM is suspended on a `HostAwait`, returns the identifier (function name) that triggered the suspension. Returns `None` if not applicable. From 41f50b2c3cf016230a538df6e04a617e162888db Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Sun, 5 Jul 2026 22:53:25 +0200 Subject: [PATCH 07/11] feat(ffi): add struct_size params for forward-compatible host-await ABI The host-await builtin and response-set structs are passed as C arrays, so their size is baked into the caller's stride arithmetic. Adding a field to either struct later (e.g. an arity) would change sizeof and make a client compiled against the old layout mis-walk the array -- a silent ABI break. Add an explicit element-size parameter to the two array-taking entry points (regorus_program_compile_from_modules_with_host_await and regorus_rvm_set_host_await_responses). The native side validates the size covers the current layout, then walks the array by the caller-supplied stride instead of native sizeof, so a caller built against a different (older/newer) struct layout still indexes correctly or fails loud rather than corrupting memory. The C# bindings pass sizeof(struct); the cbindgen headers regenerate automatically. Adds Rust unit tests covering the native-size path, the undersized-stride rejection, the oversized-stride (newer caller) forward- compat direction, and the len==0 no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/csharp/Regorus/NativeMethods.cs | 4 +- bindings/csharp/Regorus/Program.cs | 3 +- bindings/csharp/Regorus/Rvm.cs | 3 +- bindings/ffi/src/rvm.rs | 173 +++++++++++++++++++++-- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs index da9d7f636..b03b453cd 100644 --- a/bindings/csharp/Regorus/NativeMethods.cs +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -250,7 +250,7 @@ internal static unsafe partial class API /// Pre-load HostAwait responses for run-to-completion mode. /// [DllImport(LibraryName, EntryPoint = "regorus_rvm_set_host_await_responses", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_rvm_set_host_await_responses(RegorusRvm* vm, RegorusHostAwaitResponseSet* response_sets, UIntPtr response_sets_len); + internal static extern RegorusResult regorus_rvm_set_host_await_responses(RegorusRvm* vm, RegorusHostAwaitResponseSet* response_sets, UIntPtr response_sets_len, UIntPtr response_set_size); /// /// Get the HostAwait argument as a JSON string. @@ -268,7 +268,7 @@ internal static unsafe partial class API /// Compile an RVM program from data/modules/entry-points with registered host-awaitable builtins. /// [DllImport(LibraryName, EntryPoint = "regorus_program_compile_from_modules_with_host_await", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_program_compile_from_modules_with_host_await(byte* data_json, RegorusPolicyModule* modules, UIntPtr modules_len, byte** entry_points, UIntPtr entry_points_len, RegorusHostAwaitBuiltin* host_await_builtins, UIntPtr host_await_builtins_len); + internal static extern RegorusResult regorus_program_compile_from_modules_with_host_await(byte* data_json, RegorusPolicyModule* modules, UIntPtr modules_len, byte** entry_points, UIntPtr entry_points_len, RegorusHostAwaitBuiltin* host_await_builtins, UIntPtr host_await_builtins_len, UIntPtr host_await_builtin_size); /// Add a policy. /// The policy is parsed into AST. diff --git a/bindings/csharp/Regorus/Program.cs b/bindings/csharp/Regorus/Program.cs index 80d228e95..9be0db0f2 100644 --- a/bindings/csharp/Regorus/Program.cs +++ b/bindings/csharp/Regorus/Program.cs @@ -156,7 +156,8 @@ private static Program CompileFromModulesInner(string dataJson, IReadOnlyList Result<*mut RegorusProgram> { if entry_points_len == 0 { return Err(anyhow!("entry_points must contain at least one entry")); @@ -155,7 +157,11 @@ fn compile_from_modules_inner( // slice is equivalent to `compile_from_policy`, so both FFI entry points // route through this single path. A null `host_await_builtins` pointer // with `len == 0` is the canonical "no builtins" shape. - let ha_builtins = convert_c_host_await_builtins(host_await_builtins, host_await_builtins_len)?; + let ha_builtins = convert_c_host_await_builtins( + host_await_builtins, + host_await_builtins_len, + host_await_builtin_size, + )?; let ha_ref: Vec<(&str, usize)> = ha_builtins.iter().map(|(n, a)| (n.as_str(), *a)).collect(); let program = Compiler::compile_from_policy_with_host_await( @@ -200,6 +206,7 @@ pub extern "C" fn regorus_program_compile_from_modules( entry_points_len, core::ptr::null(), 0, + core::mem::size_of::(), )) }) } @@ -690,6 +697,10 @@ pub struct RegorusHostAwaitBuiltin { /// * `modules` / `modules_len` - Policy modules to compile /// * `entry_points` / `entry_points_len` - Entry point rule paths /// * `host_await_builtins` / `host_await_builtins_len` - Builtins that compile to HostAwait +/// * `host_await_builtin_size` - `sizeof(RegorusHostAwaitBuiltin)` as seen by the +/// caller; used as the array stride so callers built against a different struct +/// layout still walk the array correctly (forward-compatible ABI) +#[allow(clippy::too_many_arguments)] #[no_mangle] pub extern "C" fn regorus_program_compile_from_modules_with_host_await( data_json: *const c_char, @@ -699,6 +710,7 @@ pub extern "C" fn regorus_program_compile_from_modules_with_host_await( entry_points_len: usize, host_await_builtins: *const RegorusHostAwaitBuiltin, host_await_builtins_len: usize, + host_await_builtin_size: usize, ) -> RegorusResult { with_unwind_guard(|| { compile_from_modules_result(compile_from_modules_inner( @@ -709,6 +721,7 @@ pub extern "C" fn regorus_program_compile_from_modules_with_host_await( entry_points_len, host_await_builtins, host_await_builtins_len, + host_await_builtin_size, )) }) } @@ -735,11 +748,14 @@ pub struct RegorusHostAwaitResponseSet { /// * `vm` - RVM instance /// * `response_sets` - Array of per-identifier response sets /// * `response_sets_len` - Number of entries in `response_sets` +/// * `response_set_size` - `sizeof(RegorusHostAwaitResponseSet)` as seen by the +/// caller; used as the array stride for forward-compatible ABI #[no_mangle] pub extern "C" fn regorus_rvm_set_host_await_responses( vm: *mut RegorusRvm, response_sets: *const RegorusHostAwaitResponseSet, response_sets_len: usize, + response_set_size: usize, ) -> RegorusResult { with_unwind_guard(|| { to_regorus_result(|| -> Result<()> { @@ -750,17 +766,33 @@ pub extern "C" fn regorus_rvm_set_host_await_responses( return Err(anyhow!("null response_sets pointer")); } + // `response_set_size` is `sizeof(RegorusHostAwaitResponseSet)` as the + // caller compiled it, which is also the array stride. Validate and use + // it so a caller built against a different struct layout still walks + // the array correctly. + let min_size = core::mem::size_of::(); + if response_sets_len > 0 && response_set_size < min_size { + return Err(anyhow!( + "response_set_size ({response_set_size}) is smaller than the expected \ + RegorusHostAwaitResponseSet layout ({min_size} bytes); ABI mismatch" + )); + } + let mut all = Vec::new(); all.try_reserve(response_sets_len).map_err(|_| { anyhow!( "failed to reserve capacity for {response_sets_len} host-await response sets" ) })?; + let base = response_sets as *const u8; for i in 0..response_sets_len { - // SAFETY: caller guarantees `response_sets` points to a - // contiguous array of `response_sets_len` `RegorusHostAwaitResponseSet` - // values, and the inner pointers reference valid C strings. - let set = unsafe { &*response_sets.add(i) }; + let offset = i.checked_mul(response_set_size).ok_or_else(|| { + anyhow!("host-await response set array offset overflow at index {i}") + })?; + // SAFETY: caller guarantees `response_sets` points to a contiguous + // array of `response_sets_len` elements each `response_set_size` + // bytes wide, and the inner pointers reference valid C strings. + let set = unsafe { &*(base.add(offset) as *const RegorusHostAwaitResponseSet) }; let id_str = from_c_str(set.identifier) .map_err(|e| anyhow!("invalid identifier in response set at index {i}: {e}"))?; @@ -853,23 +885,138 @@ pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> pub(crate) fn convert_c_host_await_builtins( builtins: *const RegorusHostAwaitBuiltin, len: usize, + struct_size: usize, ) -> Result> { if builtins.is_null() && len > 0 { return Err(anyhow!("null host_await_builtins pointer")); } + // `struct_size` is `sizeof(RegorusHostAwaitBuiltin)` as the caller compiled it, + // which is also the array stride. Validate it covers the fields this build + // reads, then index by that stride so a caller built against a different + // (older/newer) struct layout still walks the array correctly. + let min_size = core::mem::size_of::(); + if len > 0 && struct_size < min_size { + return Err(anyhow!( + "host_await_builtin_size ({struct_size}) is smaller than the expected \ + RegorusHostAwaitBuiltin layout ({min_size} bytes); ABI mismatch" + )); + } let mut result = Vec::new(); result .try_reserve(len) .map_err(|_| anyhow!("failed to reserve capacity for {len} host-await builtins"))?; + let base = builtins as *const u8; for i in 0..len { - unsafe { - let b = &*builtins.add(i); - let name = from_c_str(b.name) - .map_err(|e| anyhow!("invalid host-await builtin name at index {i}: {e}"))?; - // Arg count is fixed to 1 by the compiler — see the doc comment - // on `RegorusHostAwaitBuiltin` and `Compiler::register_host_await_builtin`. - result.push((name, 1)); - } + let offset = i + .checked_mul(struct_size) + .ok_or_else(|| anyhow!("host-await builtin array offset overflow at index {i}"))?; + // SAFETY: caller guarantees `len` elements each `struct_size` bytes wide + // starting at `builtins`, with valid C-string `name` pointers. + let b = unsafe { &*(base.add(offset) as *const RegorusHostAwaitBuiltin) }; + let name = from_c_str(b.name) + .map_err(|e| anyhow!("invalid host-await builtin name at index {i}: {e}"))?; + // Arg count is fixed to 1 by the compiler — see the doc comment + // on `RegorusHostAwaitBuiltin` and `Compiler::register_host_await_builtin`. + result.push((name, 1)); } Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + + fn c(s: &str) -> CString { + CString::new(s).expect("CString::new failed") + } + + /// Simulates a *future* `RegorusHostAwaitBuiltin` that has grown a trailing + /// field. It shares the same `name: *const c_char` at offset 0, so a caller + /// built against this wider layout must still be walked correctly as long as + /// it reports its own (larger) element size as the stride. + #[repr(C)] + struct WiderBuiltin { + name: *const c_char, + _appended: u64, + } + + // A caller-supplied array laid out at the exact native element size parses fine. + #[test] + fn convert_builtins_native_size_parses_names() { + let names = [c("translate"), c("fetch")]; + let builtins: Vec = names + .iter() + .map(|n| RegorusHostAwaitBuiltin { name: n.as_ptr() }) + .collect(); + let size = core::mem::size_of::(); + + let result = + convert_c_host_await_builtins(builtins.as_ptr(), builtins.len(), size).unwrap(); + + assert_eq!( + result, + vec![("translate".to_string(), 1), ("fetch".to_string(), 1)] + ); + } + + // A caller whose element size is smaller than the native layout is rejected + // loudly (clean error) instead of walked with a bad stride. + #[test] + fn convert_builtins_undersized_stride_is_rejected() { + let name = c("translate"); + let builtins = [RegorusHostAwaitBuiltin { + name: name.as_ptr(), + }]; + let too_small = core::mem::size_of::() - 1; + + let err = convert_c_host_await_builtins(builtins.as_ptr(), builtins.len(), too_small) + .unwrap_err(); + + assert!( + err.to_string().contains("ABI mismatch"), + "expected an ABI mismatch error, got: {err}" + ); + } + + // A *newer* caller whose struct has an appended field (larger stride) is still + // walked correctly: the native side honors the caller-supplied stride and reads + // `name` at offset 0 of each element. This is the forward-compatible + // mixed-version direction (new caller + older native library). + #[test] + fn convert_builtins_oversized_stride_uses_caller_stride() { + let names = [c("translate"), c("fetch")]; + let wide: Vec = names + .iter() + .map(|n| WiderBuiltin { + name: n.as_ptr(), + _appended: 0, + }) + .collect(); + let wider_size = core::mem::size_of::(); + assert!(wider_size > core::mem::size_of::()); + + // SAFETY: `WiderBuiltin` begins with the same `name: *const c_char` field + // at offset 0 as `RegorusHostAwaitBuiltin`, and we pass the true element + // stride (`wider_size`), so every read stays in bounds. + let result = convert_c_host_await_builtins( + wide.as_ptr() as *const RegorusHostAwaitBuiltin, + wide.len(), + wider_size, + ) + .unwrap(); + + assert_eq!( + result, + vec![("translate".to_string(), 1), ("fetch".to_string(), 1)] + ); + } + + // The no-host-await path passes null/0; the size argument must be ignored + // (no array is walked, so any size — including 0 — is accepted). + #[test] + fn convert_builtins_zero_len_ignores_size() { + let result = convert_c_host_await_builtins(core::ptr::null(), 0, 0).unwrap(); + assert!(result.is_empty()); + } +} From 81e55c3c59b9c457b291778c72efa262a1f0f677 Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Sun, 5 Jul 2026 23:59:33 +0200 Subject: [PATCH 08/11] fix(compiler): reject package-qualified host-await builtin names register_host_await_builtin matched the as-written call path, so registering a data.*-qualified name (e.g. "data.other.lookup") would intercept the package-qualified call data.other.lookup(x) and silently shadow the user-defined rule/function at that path -- breaking the documented guarantee that package-qualified calls always resolve to the user rule (the reliable bypass around host interception). Reject names starting with "data." at registration. This closes the loophole while preserving the two intended uses: bare names register host functions, and builtin-namespace names (e.g. time.parse_duration_ns, which are not under data.) still override standard builtins. Adds a negative case (registered_builtin_rejects_data_qualified_name) to the registered_host_await suite; the existing builtin-override case still passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/languages/rego/compiler/mod.rs | 37 +++++++++++++------ .../rvm/rego/cases/registered_host_await.yaml | 22 +++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/languages/rego/compiler/mod.rs b/src/languages/rego/compiler/mod.rs index 3dbca039b..736db25f5 100644 --- a/src/languages/rego/compiler/mod.rs +++ b/src/languages/rego/compiler/mod.rs @@ -185,17 +185,19 @@ impl<'a> Compiler<'a> { /// Register a function name as a host-awaitable builtin. /// - /// When the compiler encounters an **unqualified** call to `name(arg)` - /// (i.e. `name(arg)` from inside the policy's own package, not - /// `data.pkg.name(arg)` or any other package-qualified form), it will - /// emit a `HostAwait` instruction with the argument and `name` as the - /// identifier, instead of treating it as a user-defined or standard - /// builtin function. - /// - /// Package-qualified calls (e.g. `data.other.name(arg)`) are **not** - /// intercepted by registration. Those resolve through the normal - /// user-defined / builtin lookup against their fully-qualified path - /// (`data.other.name`). + /// When the compiler encounters an **unqualified** call `name(arg)` from + /// inside the policy's own package (not `data.pkg.name(arg)` or any other + /// package-qualified form), it emits a `HostAwait` instruction carrying the + /// argument and `name` as the identifier, instead of treating it as a + /// user-defined or standard builtin function. Registering a bare name thus + /// adds a host function, and registering a builtin's name (e.g. + /// `time.parse_duration_ns`) overrides that standard builtin — both are + /// supported. A package-qualified call (e.g. `data.other.name(arg)`), by + /// contrast, always resolves to the user-defined rule/function at that path + /// — even when the bare name is registered — so it stays a reliable way to + /// bypass host interception. A `data.*`-qualified name is therefore rejected + /// at registration: it would intercept the qualified call too and silently + /// shadow that rule/function. /// /// `arg_count` must be exactly 1. The `HostAwait` instruction carries a /// single argument register; use object packing to pass multiple values @@ -207,6 +209,9 @@ impl<'a> Compiler<'a> { /// whitespace (whitespace-padded names would never match the /// trimmed identifier produced by the Rego parser, creating dead /// registrations), + /// - `name` is package-qualified (starts with `data.`) — a `data.*` name + /// would intercept the package-qualified call and silently shadow the + /// user-defined rule/function at that path, /// - `name` is already registered (duplicate registration is rejected /// rather than silently overwritten), /// - `arg_count` is not exactly 1. @@ -226,6 +231,16 @@ impl<'a> Compiler<'a> { } .into()); } + if name.starts_with("data.") { + return Err(CompilerError::General { + message: format!( + "host-await builtin name {name:?} must not be package-qualified \ + (start with 'data.'); it would silently shadow the user-defined \ + rule/function at that path" + ), + } + .into()); + } if self.host_await_builtins.contains_key(name) { return Err(CompilerError::General { message: format!( diff --git a/tests/rvm/rego/cases/registered_host_await.yaml b/tests/rvm/rego/cases/registered_host_await.yaml index 64c77910a..4511e849d 100644 --- a/tests/rvm/rego/cases/registered_host_await.yaml +++ b/tests/rvm/rego/cases/registered_host_await.yaml @@ -268,6 +268,28 @@ cases: # registering it as a host-await builtin is rejected at compile time. want_error: "__builtin_host_await is a reserved name" + - note: registered_builtin_rejects_data_qualified_name + data: {} + input: {} + skip_interpreter: true + execution_mode: suspendable + # Package-qualified `data.*` names are rejected at registration. Matching on + # the as-written call path, such a name would intercept a qualified call like + # `data.other.lookup(x)` and silently shadow the user-defined rule/function + # at that path, contradicting the documented contract that package-qualified + # calls are never intercepted. (Builtin-namespace names such as + # `time.parse_duration_ns` are still allowed — they do not start with `data.`.) + host_await_builtins: + - name: data.other.lookup + arg_count: 1 + modules: + - | + package demo + import rego.v1 + result := true + query: data.demo.result + want_error: "package-qualified" + - note: registered_builtin_empty_list_is_noop data: {} input: {} From 437318b635cb6e23aa7d9ac8e1c2615239d2e99f Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Wed, 15 Jul 2026 13:14:24 +0200 Subject: [PATCH 09/11] fix(csharp): keep original compile symbol when no host-await builtins Since 145ee89, every CompileFromModules path routed through regorus_program_compile_from_modules_with_host_await, including the no-builtins case. An app that updates only the managed package against an older native library (which lacks that symbol) would then hit an EntryPointNotFoundException at runtime even though it never used host-await. Restore the pre-refinement dual-path routing in CompileFromModulesInner: call the original regorus_program_compile_from_modules when there are no builtins, and only reach for the _with_host_await symbol when there is something to register. This is the routing 145ee89 collapsed; that refinement was pure consolidation and fixed no bug, so restoring the branch reintroduces nothing (the with-builtins branch still passes the struct-size argument added later). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/csharp/Regorus/Program.cs | 47 ++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/bindings/csharp/Regorus/Program.cs b/bindings/csharp/Regorus/Program.cs index 9be0db0f2..e34abbe68 100644 --- a/bindings/csharp/Regorus/Program.cs +++ b/bindings/csharp/Regorus/Program.cs @@ -137,27 +137,44 @@ private static Program CompileFromModulesInner(string dataJson, IReadOnlyList()); + + var hostAwaitBuiltinsOrEmpty = hostAwaitBuiltins ?? Array.Empty(); return Utf8Marshaller.WithUtf8(dataJson, dataPtr => { fixed (RegorusPolicyModule* modulesPtr = pinnedModules.Buffer) fixed (IntPtr* entryPtr = pinnedEntryPoints.Buffer) - fixed (RegorusHostAwaitBuiltin* builtinsPtr = pinnedBuiltins.Buffer) { - var result = API.regorus_program_compile_from_modules_with_host_await( - (byte*)dataPtr, - modulesPtr, - (UIntPtr)pinnedModules.Length, - (byte**)entryPtr, - (UIntPtr)pinnedEntryPoints.Length, - builtinsPtr, - (UIntPtr)pinnedBuiltins.Length, - (UIntPtr)sizeof(RegorusHostAwaitBuiltin)); + RegorusResult result; + if (hostAwaitBuiltinsOrEmpty.Count == 0) + { + // No host-await builtins: use the original FFI entry point so an + // app updating only the managed package against an older native + // library (without the host-await symbol) stays binary-compatible + // instead of hitting EntryPointNotFoundException. + result = API.regorus_program_compile_from_modules( + (byte*)dataPtr, + modulesPtr, + (UIntPtr)pinnedModules.Length, + (byte**)entryPtr, + (UIntPtr)pinnedEntryPoints.Length); + } + else + { + using var pinnedBuiltins = ModuleMarshalling.PinHostAwaitBuiltins(hostAwaitBuiltinsOrEmpty); + fixed (RegorusHostAwaitBuiltin* builtinsPtr = pinnedBuiltins.Buffer) + { + result = API.regorus_program_compile_from_modules_with_host_await( + (byte*)dataPtr, + modulesPtr, + (UIntPtr)pinnedModules.Length, + (byte**)entryPtr, + (UIntPtr)pinnedEntryPoints.Length, + builtinsPtr, + (UIntPtr)pinnedBuiltins.Length, + (UIntPtr)sizeof(RegorusHostAwaitBuiltin)); + } + } return GetProgramResult(result); } From 50a4fdebb58f632f6f44f9240c7038c737c453da Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Wed, 15 Jul 2026 13:19:19 +0200 Subject: [PATCH 10/11] fix(csharp): restore missing XML doc comment on CompileFromEngine The closing brace of CompileFromModules sat on the same line as the following `/// `, so the compiler did not treat it as a doc comment for CompileFromEngine and that member's XML docs silently disappeared. Add a newline before the doc comment so it is recognized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bindings/csharp/Regorus/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bindings/csharp/Regorus/Program.cs b/bindings/csharp/Regorus/Program.cs index e34abbe68..f2b64a8b3 100644 --- a/bindings/csharp/Regorus/Program.cs +++ b/bindings/csharp/Regorus/Program.cs @@ -52,7 +52,9 @@ public static Program CompileFromModules(string dataJson, IEnumerable modules, IReadOnlyList entryPoints) { return CompileFromModulesInner(dataJson, modules, entryPoints, hostAwaitBuiltins: null); - } /// + } + + /// /// Compile an RVM program from an engine instance and entry points. /// public static Program CompileFromEngine(Engine engine, IEnumerable entryPoints) From f1ac26af3277abe2d35b4d3f9e7ef8bb14d25935 Mon Sep 17 00:00:00 2001 From: Mark Birger Date: Fri, 17 Jul 2026 14:16:17 +0200 Subject: [PATCH 11/11] fix(csharp): reject embedded NUL in host-await identifiers Strings passed to native code become null-terminated C strings, and Rust's CStr stops at the first NUL. So a host-await identifier containing an embedded NUL (e.g. new HostAwaitBuiltin("foo\0bar")) would be silently truncated ("foo") and mis-route, with no downstream check to catch it. Add a shared Utf8Marshaller.ThrowIfContainsNul helper and apply it to the two identifier write-points: the HostAwaitBuiltin constructor (registration name) and the response key in PinHostAwaitResponseSets. Identifiers are the "address" of a host-await and have no parser backstop, so truncation there is silent mis-routing. Response values are deliberately not validated: they are payload, backstopped by from_json_str, and a raw NUL in a value is the accepted binding-wide "client responsibility" contract shared with AddDataJson / SetInputJson (a raw NUL truncating to invalid JSON surfaces as a loud parse error; one truncating to valid JSON is silently accepted, same as elsewhere). Tests: RejectsEmbeddedNulInIdentifier (name + response key), escaped-\u0000 round-trip on preset and resume value paths, and a raw-NUL resume value that throws from the parse backstop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../csharp/Regorus.Tests/RvmProgramTests.cs | 110 ++++++++++++++++++ bindings/csharp/Regorus/Compiler.cs | 2 + bindings/csharp/Regorus/ModuleMarshalling.cs | 2 + bindings/csharp/Regorus/Utf8Marshaller.cs | 15 +++ 4 files changed, 129 insertions(+) diff --git a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs index fb025aed8..1c562f22f 100644 --- a/bindings/csharp/Regorus.Tests/RvmProgramTests.cs +++ b/bindings/csharp/Regorus.Tests/RvmProgramTests.cs @@ -237,6 +237,116 @@ public void RegisteredHostAwait_CompileRejectsReservedName() "expected compilation to reject reserved __builtin_host_await identifier"); } + public static IEnumerable EmbeddedNulIdentifierScenarios() + { + // Identifiers (registration name + response key) are raw C strings with + // no downstream parser to catch truncation, so an embedded NUL would + // silently mis-route. Both write-points reject it. + yield return new object[] + { + "builtin name", + (Action)(() => _ = new HostAwaitBuiltin("bad\0name")), + }; + yield return new object[] + { + "response identifier", + (Action)(() => + { + using var vm = new Rvm(); + vm.SetHostAwaitResponses(new Dictionary> + { + ["bad\0id"] = new[] { "\"v\"" }, + }); + }), + }; + } + + // A raw NUL in an identifier is rejected loudly at the public surface, + // before Rust's CStr can silently truncate it. + [DataTestMethod] + [DynamicData(nameof(EmbeddedNulIdentifierScenarios), DynamicDataSourceType.Method)] + public void RegisteredHostAwait_RejectsEmbeddedNulInIdentifier(string description, Action action) + { + Assert.ThrowsException( + action, + $"expected embedded NUL in {description} to be rejected before crossing the FFI boundary"); + } + + // A JSON-escaped null (\u0000) is six ASCII chars, not a raw NUL, so it + // round-trips faithfully. Response values are not NUL-validated (client + // responsibility, consistent with other JSON inputs). + [TestMethod] + public void RegisteredHostAwait_ResponseValueWithEscapedNul_RoundTrips() + { + var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.RunToCompletion); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + + vm.SetHostAwaitResponses(new Dictionary> + { + ["translate"] = new[] { "\"a\\u0000b\"" }, + }); + + var result = vm.Execute(); + + Assert.AreEqual("\"a\\u0000b\"", result); + } + + // Same escaped-null round-trip on the resume path (customer-controlled JSON, + // not NUL-validated). + [TestMethod] + public void RegisteredHostAwait_ResumeValueWithEscapedNul_RoundTrips() + { + var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.Suspendable); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + + vm.Execute(); + Assert.AreEqual("translate", vm.GetHostAwaitIdentifier()); + + var result = vm.Resume("\"a\\u0000b\""); + + Assert.AreEqual("\"a\\u0000b\"", result); + } + + // Failing scenario: a raw NUL in the resume value is not validated, but + // Rust's CStr truncates at it and the truncated text ("a) is invalid JSON, + // so it surfaces as a loud parse error. (A raw NUL that truncates to *valid* + // JSON — e.g. "123\0456" -> 123 — is silently accepted, the accepted + // binding-wide value contract shared with AddDataJson/SetInputJson.) + [TestMethod] + public void RegisteredHostAwait_ResumeValueWithRawNul_ThrowsFromParse() + { + var modules = new[] { new PolicyModule("translate.rego", TranslatePolicy) }; + var entryPoints = new[] { "data.demo.greeting" }; + var hostAwaitBuiltins = new[] { new HostAwaitBuiltin("translate") }; + + using var program = Program.CompileFromModules("{}", modules, entryPoints, hostAwaitBuiltins); + using var vm = new Rvm(); + vm.SetExecutionMode(ExecutionMode.Suspendable); + vm.LoadProgram(program); + vm.SetInputJson("{\"lang\": \"es\"}"); + + vm.Execute(); + Assert.AreEqual("translate", vm.GetHostAwaitIdentifier()); + + Assert.ThrowsException( + () => vm.Resume("\"a\0b\""), + "expected a raw NUL that truncates to invalid JSON to surface as a parse error"); + } + [TestMethod] public void RegisteredHostAwait_GetAccessorsReturnNullWhenVmIsNotSuspended() { diff --git a/bindings/csharp/Regorus/Compiler.cs b/bindings/csharp/Regorus/Compiler.cs index 4462e77f7..4ec3d97ca 100644 --- a/bindings/csharp/Regorus/Compiler.cs +++ b/bindings/csharp/Regorus/Compiler.cs @@ -61,9 +61,11 @@ public readonly struct HostAwaitBuiltin /// /// The function name to register as host-awaitable. /// Thrown when is null. + /// Thrown when contains an embedded NUL ('\0'). public HostAwaitBuiltin(string name) { Name = name ?? throw new ArgumentNullException(nameof(name)); + Utf8Marshaller.ThrowIfContainsNul(name, nameof(name)); } } diff --git a/bindings/csharp/Regorus/ModuleMarshalling.cs b/bindings/csharp/Regorus/ModuleMarshalling.cs index e22236c92..6d8521399 100644 --- a/bindings/csharp/Regorus/ModuleMarshalling.cs +++ b/bindings/csharp/Regorus/ModuleMarshalling.cs @@ -311,6 +311,8 @@ internal static PinnedHostAwaitResponseSets PinHostAwaitResponseSets( nameof(responsesByIdentifier)); } + Utf8Marshaller.ThrowIfContainsNul(kvp.Key, nameof(responsesByIdentifier)); + var idPinned = Utf8Marshaller.Pin(kvp.Key); pins.Add(idPinned); diff --git a/bindings/csharp/Regorus/Utf8Marshaller.cs b/bindings/csharp/Regorus/Utf8Marshaller.cs index d903056be..e102015dc 100644 --- a/bindings/csharp/Regorus/Utf8Marshaller.cs +++ b/bindings/csharp/Regorus/Utf8Marshaller.cs @@ -151,6 +151,21 @@ internal static PinnedUtf8 Pin(string value) return new PinnedUtf8(value); } + /// + /// Throws if contains an embedded NUL ('\0'), + /// which would silently truncate the string when passed to native code + /// as a null-terminated C string. A null value is left to the caller. + /// + internal static void ThrowIfContainsNul(string value, string paramName) + { + if (value != null && value.IndexOf('\0') >= 0) + { + throw new ArgumentException( + "Value must not contain an embedded NUL ('\\0'); it would be silently truncated when passed to native code.", + paramName); + } + } + internal static unsafe string? FromUtf8(byte* pointer) { if (pointer is null)