using System.Collections.Concurrent; using System.Text.Json; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Types; namespace ScadaLink.InboundAPI; /// /// WP-3: 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; // InboundAPI-001: 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(); // InboundAPI-009: a script that fails to compile (or violates the trust model) // is recorded here 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. The entry is // cleared whenever the method is (re)compiled via CompileAndRegister. private readonly ConcurrentDictionary _knownBadMethods = new(); private readonly IServiceProvider _serviceProvider; public InboundScriptExecutor(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } /// /// Registers a compiled script handler for a method name. /// public void RegisterHandler(string methodName, Func> handler) { _scriptHandlers[methodName] = handler; } /// /// Removes a compiled script handler for a method name. /// 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. /// public bool CompileAndRegister(ApiMethod method) { var handler = Compile(method); if (handler == null) { // InboundAPI-009: record the failure so the lazy-compile path does not // keep recompiling a broken script on every request. _knownBadMethods[method.Name] = 0; return false; } // The method definition was (re)compiled successfully — drop any stale // failure record so a fixed script is no longer treated as bad. _knownBadMethods.TryRemove(method.Name, out _); return Register(method.Name, handler); } private bool Register(string methodName, Func> handler) { _scriptHandlers[methodName] = 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 (InboundAPI-005). Does not mutate the handler cache. /// private Func>? Compile(ApiMethod method) { if (string.IsNullOrWhiteSpace(method.Script)) { _logger.LogWarning("API method {Method} has no script code", method.Name); return null; } // InboundAPI-005: 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; } 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; } _logger.LogInformation("API method {Method} script compiled", method.Name); return async ctx => { var state = await compiled.RunAsync(ctx, ctx.CancellationToken); return state.ReturnValue; }; } catch (Exception ex) { _logger.LogError(ex, "Failed to compile API method {Method} script", method.Name); return null; } } /// /// Executes the script for the given method with the provided context. /// /// /// Audit Log #23 (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. /// 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) { // InboundAPI-004: 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); try { // InboundAPI-016: 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. // // Audit Log #23 (ParentExecutionId): also bind the inbound request's // ExecutionId 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).WithParentExecutionId(parentExecutionId), cts.Token); if (!_scriptHandlers.TryGetValue(method.Name, out var handler)) { // InboundAPI-009: a method already known to fail compilation must not // be recompiled on every request — short-circuit before Roslyn runs. if (_knownBadMethods.ContainsKey(method.Name)) return new InboundScriptResult(false, null, "Script compilation failed for this method"); // Lazy compile on first request (handles methods created after startup). // Compile outside the cache so a failed compile is not stored, then add // atomically 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. _knownBadMethods[method.Name] = 0; return new InboundScriptResult(false, null, "Script compilation failed for this method"); } handler = _scriptHandlers.GetOrAdd(method.Name, compiled); } var result = await handler(context).WaitAsync(cts.Token); var resultJson = result != null ? JsonSerializer.Serialize(result) : null; // InboundAPI-014: 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); 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) { // InboundAPI-004: 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); // WP-5: Safe error message, no internal details return new InboundScriptResult(false, null, "Internal script error"); } } } /// /// Context provided to inbound API scripts. /// public class InboundScriptContext { public ScriptParameters Parameters { get; } public RouteHelper Route { get; } public CancellationToken CancellationToken { get; } public InboundScriptContext( IReadOnlyDictionary parameters, RouteHelper route, CancellationToken cancellationToken = default) { Parameters = new ScriptParameters(parameters); Route = route; CancellationToken = cancellationToken; } } /// /// Result of executing an inbound API script. /// public record InboundScriptResult( bool Success, string? ResultJson, string? ErrorMessage);