using System.Collections.Concurrent; using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// /// Executes the C# script associated with an inbound API method. /// Compiles method scripts via Roslyn and caches compiled delegates. /// public class InboundScriptExecutor { private readonly ILogger _logger; // A cached compiled handler, paired with the exact script text it was // compiled from. The DB row is authoritative: on every request the cached // Script is ordinal-compared against the freshly-fetched ApiMethod.Script; a // mismatch means the cache is stale (a standby node compiled its own copy at // startup, or the row was edited via direct SQL / bundle import) and the // handler is recompiled in place. A null Script means "no revision info, // serve as-is" — the RegisterHandler test seam, exempt from revision checks. private sealed record CachedHandler(string? Script, Func> Handler); // This executor is registered as a singleton and its handler cache // is read and written from concurrent ASP.NET request threads. A plain Dictionary is // not safe for concurrent read/write, so a ConcurrentDictionary is used throughout. private readonly ConcurrentDictionary _scriptHandlers = new(); // A script that fails to compile (or violates the trust model) // is recorded here — keyed by method name, valued by the exact script text // that failed — so it is compiled at most once. Without this, every subsequent // request for a broken method re-runs the expensive Roslyn compilation — a CPU // amplification vector since the inbound API has no rate limiting. A record only // short-circuits when the CURRENT script text is the one that failed: a changed // script always gets a fresh compile. The entry is cleared whenever the method // is (re)compiled successfully. // // Bound the cache so a spam attack of unique method names cannot // grow it without bound. Once the cap is reached new bad-method records are // dropped — the cache is just a fast-fail optimisation; the per-request DB // lookup remains the correctness path. private const int KnownBadMethodsCap = 1000; private readonly ConcurrentDictionary _knownBadMethods = new(); /// /// Diagnostic helper — returns the current size of the /// known-bad-methods cache so tests can assert the cap is honoured. Internal /// so the cache itself stays an implementation detail. /// internal int KnownBadMethodCount => _knownBadMethods.Count; /// /// Records in the /// known-bad-methods cache so the CURRENT failing script text short-circuits the /// next request. An existing record for the method is overwritten so a newer bad /// script replaces an older one (the fast-fail always tracks the latest failure). /// A brand-new record is only added while the cache has not reached /// ; once full, new method names are dropped /// (paying the cheap recompile next time rather than leaking memory under a /// unique-name flood). Records are cleared on a successful (re)compile. /// private void TryRecordBadMethod(string methodName, string script) { // Overwrite an existing record (newer bad script wins) without counting // against the cap; only a genuinely new method name is gated by the cap. if (_knownBadMethods.ContainsKey(methodName)) { _knownBadMethods[methodName] = script; return; } if (_knownBadMethods.Count >= KnownBadMethodsCap) return; _knownBadMethods.TryAdd(methodName, script); } private readonly IServiceProvider _serviceProvider; /// /// Initializes a new instance of the InboundScriptExecutor. /// /// Logger instance. /// Service provider for dependency resolution. public InboundScriptExecutor(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } /// /// Registers a compiled script handler for a method name. This is the script-less /// test seam: the handler is stored with no associated script text, so it is /// exempt from the per-request revision check and serves as-is regardless of /// the fetched row's script. Production compiles always flow through /// , which records the script text and is /// revision-checked. /// /// The method name to register. /// The compiled handler function. public void RegisterHandler(string methodName, Func> handler) { _scriptHandlers[methodName] = new CachedHandler(null, handler); } /// /// Removes a compiled script handler for a method name. /// /// The method name to remove. public void RemoveHandler(string methodName) { _scriptHandlers.TryRemove(methodName, out _); } /// /// Compiles and registers a single API method script. Returns false if the /// script is empty, fails Roslyn compilation, or violates the script trust model. /// /// The API method to compile and register. /// True if successfully compiled and registered, false otherwise. public bool CompileAndRegister(ApiMethod method) => CompileAndRegister(method, out _); /// /// Compiles and registers a single API method script, additionally reporting the /// compilation diagnostics. Returns false together with the human-readable /// error list if the script is empty, fails Roslyn compilation, or violates the /// script trust model. On failure this method does not overwrite any previously /// registered handler, but the persisted DB row is authoritative: /// revision-checks the cached handler against the freshly-fetched /// ApiMethod.Script on every request, so once a broken script is saved the /// method returns a compilation error consistently on every node rather than the /// stale delegate silently serving. The management Create/Update path can therefore /// surface a "saved but did not compile" warning that reflects real, consistent /// behavior (see HandleCreateApiMethod/HandleUpdateApiMethod). /// /// The API method to compile and register. /// On failure, the compilation/trust-model error messages; empty on success. /// True if successfully compiled and registered, false otherwise. public bool CompileAndRegister(ApiMethod method, out IReadOnlyList errors) { var (handler, compileErrors) = Compile(method); if (handler == null) { // Record the failure so the lazy-compile path does not // keep recompiling a broken script on every request. Routed // through the capped TryRecordBadMethod helper so the cache // cannot grow without bound under a flood of unique method names. errors = compileErrors; TryRecordBadMethod(method.Name, method.Script ?? string.Empty); return false; } // The method definition was (re)compiled successfully — drop any stale // failure record so a fixed script is no longer treated as bad. errors = Array.Empty(); _knownBadMethods.TryRemove(method.Name, out _); return Register(method, handler); } private bool Register(ApiMethod method, Func> handler) { _scriptHandlers[method.Name] = new CachedHandler(method.Script, handler); return true; } /// /// Compiles a single API method script into an executable handler. Returns /// null when the script is missing, fails to compile, or violates the /// script trust model. Does not mutate the handler cache. /// private (Func>? Handler, IReadOnlyList Errors) Compile(ApiMethod method) { if (string.IsNullOrWhiteSpace(method.Script)) { _logger.LogWarning("API method {Method} has no script code", method.Name); return (null, new[] { "Method has no script code." }); } // Enforce the script trust model before compiling. Roslyn // scripting performs no API allow/deny-listing, so forbidden namespaces must // be rejected statically or the script could reach the host process. var violations = ForbiddenApiChecker.FindViolations(method.Script); if (violations.Count > 0) { _logger.LogWarning( "API method {Method} script rejected — trust model violation(s): {Violations}", method.Name, string.Join("; ", violations)); return (null, violations.Select(v => "Trust model violation: " + v).ToList()); } try { var scriptOptions = ScriptOptions.Default .WithReferences( typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(Dictionary<,>).Assembly, typeof(RouteHelper).Assembly, typeof(ScriptParameters).Assembly, typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly) .WithImports( "System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); var compiled = CSharpScript.Create( method.Script, scriptOptions, globalsType: typeof(InboundScriptContext)); var diagnostics = compiled.Compile(); var errors = diagnostics .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) .Select(d => d.GetMessage()) .ToList(); if (errors.Count > 0) { _logger.LogWarning( "API method {Method} script compilation failed: {Errors}", method.Name, string.Join("; ", errors)); return (null, errors); } _logger.LogInformation("API method {Method} script compiled", method.Name); return (async ctx => { var state = await compiled.RunAsync(ctx, ctx.CancellationToken); return state.ReturnValue; }, Array.Empty()); } catch (Exception ex) { _logger.LogError(ex, "Failed to compile API method {Method} script", method.Name); return (null, new[] { "Compilation error: " + ex.Message }); } } /// /// Executes the script for the given method with the provided context. /// /// The API method containing the script to execute. /// Input parameters for the script. /// Route helper for cross-site routing. /// Timeout duration for script execution. /// Cancellation token. /// /// ParentExecutionId: the inbound API request's per-request /// ExecutionId (minted early by AuditWriteMiddleware and stashed /// on HttpContext.Items). When supplied, a routed /// Route.To(...).Call(...) inside the script carries it as /// so the spawned site /// script execution points back at this inbound request. Null when the script /// runs outside an inbound API request flow. /// /// The result of executing the script. public async Task ExecuteAsync( ApiMethod method, IReadOnlyDictionary parameters, RouteHelper route, TimeSpan timeout, CancellationToken cancellationToken = default, // Deliberate ordering: this optional parameter trails the CancellationToken // because it was appended additively for non-breaking contract evolution. // Every call site passes it by named argument (parentExecutionId:). Guid? parentExecutionId = null) { // Keep the timeout source and the request-abort source // separable. A single linked CTS makes a genuine client disconnect // indistinguishable from a method timeout, so a normal disconnect would be // logged and reported as "Script execution timed out". Use a dedicated // timeout CTS, linked with the request token, so the two can be told apart. using var timeoutCts = new CancellationTokenSource(timeout); using var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCts.Token); // IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script // (reads + writes). Resolve the gateway from a fresh DI scope // (declared outside the try so it lives until after the script handler runs and is // disposed here) so a scoped IDatabaseGateway is honoured. GetService (not // GetRequiredService) so a method that never touches Database still runs even when // no IDatabaseGateway is registered; the helper throws on first use if built // without a gateway. // // A service provider that cannot produce a scope (e.g. a bare test double with // no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so // a script not using Database still runs, while one that does fails on use. IServiceScope? scope = null; try { scope = _serviceProvider.CreateScope(); } catch (InvalidOperationException ex) { // No scope factory available (e.g. a non-scoping test-double provider). // In production this should never happen; log so a genuine container // misconfiguration is visible rather than silently disabling Database. _logger.LogWarning(ex, "Could not create a DI scope for method {Method}; Database will be unavailable to its script", method.Name); } try { var gateway = scope?.ServiceProvider.GetService(); // Pass the method timeout so the helper derives a // CommandTimeout backstop and forwards cts.Token to every async DB call — // a slow query is then bounded by the method deadline. var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout); // Bind the route helper to the method deadline so a // routed Route.To(...).Call(...) inherits the method-level timeout // without the script having to thread the context token by hand. // // Also pass the raw request-abort token separately so // Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout // (not the generic method deadline) while still being cancelled by a // client disconnect — see RouteTarget.WaitForAttribute. // // Also bind the inbound request's ExecutionId (ParentExecutionId) so a // routed call carries it as ParentExecutionId — the // spawned site script execution points back at this inbound request. var context = new InboundScriptContext( parameters, route.WithDeadline(cts.Token) .WithRequestAborted(cancellationToken) .WithParentExecutionId(parentExecutionId), dbHelper, cts.Token); _scriptHandlers.TryGetValue(method.Name, out var cached); // Revision check: a cached handler compiled from different script text is stale // (standby-after-failover, direct SQL edit, bundle import). Null Script = test seam, exempt. if (cached is { Script: not null } && !string.Equals(cached.Script, method.Script, StringComparison.Ordinal)) { _logger.LogInformation( "API method {Method} script changed since it was cached; recompiling", method.Name); cached = null; } if (cached == null) { // Fast-fail only when the CURRENT script text is the one already known bad. if (_knownBadMethods.TryGetValue(method.Name, out var badScript) && string.Equals(badScript, method.Script, StringComparison.Ordinal)) return new InboundScriptResult(false, null, "Script compilation failed for this method"); // Lazy compile (handles methods created after startup, and stale/known-bad // recompiles). Compile outside the cache so a failed compile is not stored, // then add so concurrent first-callers share a single handler instance. var (compiled, _) = Compile(method); if (compiled == null) { // Cache the failure so the next request short-circuits above. // Routed through TryRecordBadMethod so the // cache is bounded under a flood of unique method names. TryRecordBadMethod(method.Name, method.Script ?? string.Empty); return new InboundScriptResult(false, null, "Script compilation failed for this method"); } _knownBadMethods.TryRemove(method.Name, out _); cached = new CachedHandler(method.Script, compiled); _scriptHandlers[method.Name] = cached; // last-write-wins; concurrent first-callers race benignly } var result = await cached.Handler(context).WaitAsync(cts.Token); var resultJson = result != null ? JsonSerializer.Serialize(result) : null; // Thread the JSON-Schema $ref resolution seam into return // validation so a method whose ReturnDefinition uses a {"$ref":"lib:Name"} // resolves the reference at RUNTIME (not just at deploy time). The // shared-schema library is pre-loaded ONCE from the per-execution DI scope // (the seam the validator consumes is synchronous), and ONLY when the // definition actually uses a $ref — a $ref-free return definition skips the // repository round-trip entirely. A dangling ref surfaces as a descriptive // validation failure naming the missing reference, not an opaque parse error. var sharedSchemaRepo = scope?.ServiceProvider.GetService(); var resolveRef = await SchemaRefResolver.BuildAsync( sharedSchemaRepo, [method.ReturnDefinition], cts.Token); // Validate the script's return value against the // method's declared ReturnDefinition. A method whose script returns a // shape inconsistent with its definition must not silently emit a // malformed 200 — surface it as a script failure (500) and log. var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition, resolveRef); if (!returnValidation.IsValid) { _logger.LogWarning( "API method {Method} return value rejected: {Error}", method.Name, returnValidation.ErrorMessage); return new InboundScriptResult(false, null, "Method return value did not match its return definition"); } return new InboundScriptResult(true, resultJson, null); } catch (OperationCanceledException) { // Distinguish a genuine method timeout from a client // abort. Only the timeout CTS firing is a real timeout; if the caller's // request token fired, the client disconnected — do not pollute the // timeout log (reserved for genuine script-execution timeouts). if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { _logger.LogWarning("Script execution timed out for method {Method}", method.Name); return new InboundScriptResult(false, null, "Script execution timed out"); } _logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name); return new InboundScriptResult(false, null, "Request cancelled by client"); } catch (Exception ex) { _logger.LogError(ex, "Script execution failed for method {Method}", method.Name); // Safe error message, no internal details return new InboundScriptResult(false, null, "Internal script error"); } finally { // Dispose the per-execution DI scope (if one was created) only after the // script handler has finished running and its result been serialized. scope?.Dispose(); } } } /// /// Context provided to inbound API scripts. /// public class InboundScriptContext { /// /// The script parameters. /// public ScriptParameters Parameters { get; } /// /// The route helper for cross-site routing. /// public RouteHelper Route { get; } /// /// Scoped, parameterized database access for the script (named connections; bound /// parameters; reads and writes — see ). /// public InboundDatabaseHelper Database { get; } /// /// The cancellation token for script execution. /// public CancellationToken CancellationToken { get; } /// /// Initializes a new instance of the InboundScriptContext. /// /// The input parameters for the script. /// The route helper for cross-site routing. /// The scoped, parameterized database helper for the script. /// The cancellation token. public InboundScriptContext( IReadOnlyDictionary parameters, RouteHelper route, InboundDatabaseHelper database, CancellationToken cancellationToken = default) { Parameters = new ScriptParameters(parameters); Route = route; Database = database; CancellationToken = cancellationToken; } } /// /// Result of executing an inbound API script. /// public record InboundScriptResult( bool Success, string? ResultJson, string? ErrorMessage);