Files
ScadaBridge/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs
T

274 lines
11 KiB
C#

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.Types;
namespace ScadaLink.InboundAPI;
/// <summary>
/// WP-3: Executes the C# script associated with an inbound API method.
/// Compiles method scripts via Roslyn and caches compiled delegates.
/// </summary>
public class InboundScriptExecutor
{
private readonly ILogger<InboundScriptExecutor> _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<string, Func<InboundScriptContext, Task<object?>>> _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<string, byte> _knownBadMethods = new();
private readonly IServiceProvider _serviceProvider;
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
/// <summary>
/// Registers a compiled script handler for a method name.
/// </summary>
public void RegisterHandler(string methodName, Func<InboundScriptContext, Task<object?>> handler)
{
_scriptHandlers[methodName] = handler;
}
/// <summary>
/// Removes a compiled script handler for a method name.
/// </summary>
public void RemoveHandler(string methodName)
{
_scriptHandlers.TryRemove(methodName, out _);
}
/// <summary>
/// Compiles and registers a single API method script. Returns <c>false</c> if the
/// script is empty, fails Roslyn compilation, or violates the script trust model.
/// </summary>
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<InboundScriptContext, Task<object?>> handler)
{
_scriptHandlers[methodName] = handler;
return true;
}
/// <summary>
/// Compiles a single API method script into an executable handler. Returns
/// <c>null</c> when the script is missing, fails to compile, or violates the
/// script trust model (InboundAPI-005). Does not mutate the handler cache.
/// </summary>
private Func<InboundScriptContext, Task<object?>>? 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<object?>(
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;
}
}
/// <summary>
/// Executes the script for the given method with the provided context.
/// </summary>
public async Task<InboundScriptResult> ExecuteAsync(
ApiMethod method,
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
// 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.
var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), 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");
}
}
}
/// <summary>
/// Context provided to inbound API scripts.
/// </summary>
public class InboundScriptContext
{
public ScriptParameters Parameters { get; }
public RouteHelper Route { get; }
public CancellationToken CancellationToken { get; }
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
CancellationToken = cancellationToken;
}
}
/// <summary>
/// Result of executing an inbound API script.
/// </summary>
public record InboundScriptResult(
bool Success,
string? ResultJson,
string? ErrorMessage);