diff --git a/bindings/csharp/API.md b/bindings/csharp/API.md index a8b0c83f..4bffa9eb 100644 --- a/bindings/csharp/API.md +++ b/bindings/csharp/API.md @@ -343,6 +343,112 @@ 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. 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(); +} +``` + +### 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). + +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 HostAwaitBuiltin(string name); +} +``` + +### 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 0d5669bf..37ddea27 100644 --- a/bindings/csharp/README.md +++ b/bindings/csharp/README.md @@ -105,6 +105,89 @@ 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") }; + +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") }; + +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. 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" +``` + ## 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 ee65c680..1c562f22 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; @@ -116,4 +117,338 @@ 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") }; + + 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") }; + + 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(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"); + } + + 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() + { + 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 ea036104..4ec3d97c 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. + /// + /// + /// 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 to register as host-awaitable. + /// + public string Name { get; } + + /// + /// Initializes a new instance of the HostAwaitBuiltin struct. + /// + /// 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)); + } + } + /// /// 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 2a7983e1..6d852139 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 @@ -45,12 +46,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 +120,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 +135,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 +153,222 @@ 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, + }; + } + + return new PinnedHostAwaitBuiltins(buffer, count, pins); + } + catch + { + foreach (var pin in pins) + { + pin.Dispose(); + } + + ArrayPool.Shared.Return(buffer, clearArray: true); + throw; + } + } + + internal sealed class PinnedHostAwaitResponseSets : IDisposable + { + private readonly List _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)); + } + + Utf8Marshaller.ThrowIfContainsNul(kvp.Key, 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 327d5b15..b03b453c 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, RegorusHostAwaitResponseSet* response_sets, UIntPtr response_sets_len, UIntPtr response_set_size); + + /// + /// 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 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); + + /// + /// 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, UIntPtr host_await_builtin_size); + /// 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,25 @@ internal unsafe partial struct RegorusAliasRegistry { } + /// + /// FFI wrapper for HostAwaitBuiltin struct. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusHostAwaitBuiltin + { + public byte* name; + } + + /// + /// 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 26448075..f2b64a8b 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) + if (hostAwaitBuiltins is null) { - throw new ArgumentNullException(nameof(engine)); - } - if (entryPoints 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,16 +137,46 @@ 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 => + var hostAwaitBuiltinsOrEmpty = hostAwaitBuiltins ?? Array.Empty(); + + 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); + 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); } diff --git a/bindings/csharp/Regorus/Rvm.cs b/bindings/csharp/Regorus/Rvm.cs index 45c2fda3..f8ec86bd 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 @@ -213,6 +214,66 @@ 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 raw UTF-8 string (not JSON-quoted). + /// 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. + /// + /// + /// 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 (responsesByIdentifier is null) + { + throw new ArgumentNullException(nameof(responsesByIdentifier)); + } + + using var pinnedSets = ModuleMarshalling.PinHostAwaitResponseSets(responsesByIdentifier); + + UseHandle(vmPtr => + { + fixed (RegorusHostAwaitResponseSet* setsPtr = pinnedSets.Buffer) + { + CheckAndDropResult(API.regorus_rvm_set_host_await_responses( + (RegorusRvm*)vmPtr, + setsPtr, + (UIntPtr)pinnedSets.Length, + (UIntPtr)sizeof(RegorusHostAwaitResponseSet))); + } + return 0; + }); + } + private static Rvm GetRvmResult(RegorusResult result) { try diff --git a/bindings/csharp/Regorus/Utf8Marshaller.cs b/bindings/csharp/Regorus/Utf8Marshaller.cs index d903056b..e102015d 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) diff --git a/bindings/ffi/src/rvm.rs b/bindings/ffi/src/rvm.rs index 7603305c..c524d379 100644 --- a/bindings/ffi/src/rvm.rs +++ b/bindings/ffi/src/rvm.rs @@ -123,6 +123,65 @@ 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. +#[allow(clippy::too_many_arguments)] +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, + host_await_builtins: *const RegorusHostAwaitBuiltin, + host_await_builtins_len: usize, + host_await_builtin_size: 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(); + + // 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())?; + + // `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, + 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( + &compiled_policy, + &entry_points_ref, + &ha_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 +198,16 @@ 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, + core::ptr::null(), + 0, + core::mem::size_of::(), + )) }) } @@ -640,3 +676,347 @@ fn convert_c_modules_to_rust( Ok(policy_modules) } + +/// 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, +} + +/// 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 +/// * `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, + 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, + host_await_builtin_size: usize, +) -> RegorusResult { + with_unwind_guard(|| { + 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, + host_await_builtin_size, + )) + }) +} + +/// 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. +/// +/// 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 +/// * `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<()> { + let vm = to_shared_ref(vm as *const RegorusRvm)?; + let mut guard = vm.try_write()?; + + if response_sets.is_null() && response_sets_len > 0 { + 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 { + 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}"))?; + 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::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| { + 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(all); + 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_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()?)), + 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 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] +pub extern "C" fn regorus_rvm_get_host_await_identifier(vm: *mut RegorusRvm) -> RegorusResult { + with_unwind_guard(|| { + let output = || -> Result> { + let vm = to_shared_ref(vm as *const RegorusRvm)?; + let guard = vm.try_read()?; + match guard.get_host_await_identifier() { + 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(identifier)) => RegorusResult::ok_string(identifier), + Ok(None) => RegorusResult::ok_void(), + Err(err) => RegorusResult::err_with_message(RegorusStatus::Error, err.to_string()), + } + }) +} + +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 { + 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()); + } +} diff --git a/docs/rvm/vm-runtime.md b/docs/rvm/vm-runtime.md index b7ec5580..85c9cb4f 100644 --- a/docs/rvm/vm-runtime.md +++ b/docs/rvm/vm-runtime.md @@ -53,6 +53,13 @@ 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. 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. --- diff --git a/src/languages/rego/compiler/mod.rs b/src/languages/rego/compiler/mod.rs index 3dbca039..736db25f 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/src/rvm/vm/machine.rs b/src/rvm/vm/machine.rs index 48a07860..59f6d6fb 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> { diff --git a/tests/rvm/rego/cases/registered_host_await.yaml b/tests/rvm/rego/cases/registered_host_await.yaml index 64c77910..4511e849 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: {}