250 lines
11 KiB
C#
250 lines
11 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
/// <summary>
|
|
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
|
|
/// The forbidden-API verdict is delegated to the shared authoritative
|
|
/// <see cref="ScriptTrustValidator"/> (M3.1 consolidation); this service keeps the real
|
|
/// execution-path compile of the script against <see cref="ScriptGlobals"/> /
|
|
/// <see cref="TriggerExpressionGlobals"/>.
|
|
/// </summary>
|
|
public class ScriptCompilationService
|
|
{
|
|
private readonly ILogger<ScriptCompilationService> _logger;
|
|
|
|
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// SiteRuntime-011: validates that the script does not reference forbidden APIs.
|
|
///
|
|
/// As of the M3.1 script-analysis consolidation this delegates to the shared
|
|
/// authoritative <see cref="ScriptTrustValidator.FindViolations(string, System.Collections.Generic.IEnumerable{MetadataReference})"/>,
|
|
/// which is the same Roslyn semantic-symbol analysis this service previously hosted
|
|
/// plus reflection-gateway / <c>dynamic</c> / <c>Activator</c> hardening ported from
|
|
/// the InboundAPI checker. The shared validator is the single source of truth for the
|
|
/// forbidden-API deny-list; SiteRuntime retains only the real execution-path compile
|
|
/// in <see cref="CompileCore"/>.
|
|
///
|
|
/// Returns a list of violation messages, empty if clean.
|
|
/// </summary>
|
|
/// <param name="code">The script code to validate.</param>
|
|
/// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
|
|
public IReadOnlyList<string> ValidateTrustModel(string code)
|
|
=> ScriptTrustValidator.FindViolations(code);
|
|
|
|
/// <summary>
|
|
/// Assemblies referenced by compiled scripts, used to build the Roslyn scripting
|
|
/// options for the real execution-path compile.
|
|
/// </summary>
|
|
private static readonly System.Reflection.Assembly[] ScriptAssemblies =
|
|
[
|
|
typeof(object).Assembly,
|
|
typeof(Enumerable).Assembly,
|
|
typeof(Math).Assembly,
|
|
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
|
typeof(Commons.Types.DynamicJsonElement).Assembly
|
|
];
|
|
|
|
/// <summary>
|
|
/// Shared Roslyn scripting options (references + imports) used by both full
|
|
/// script compilation and trigger-expression compilation.
|
|
/// </summary>
|
|
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
|
|
.WithReferences(ScriptAssemblies)
|
|
.WithImports(
|
|
"System",
|
|
"System.Collections.Generic",
|
|
"System.Linq",
|
|
"System.Threading.Tasks");
|
|
|
|
/// <summary>
|
|
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
|
/// and parameters dictionary, and returns an object? result.
|
|
/// </summary>
|
|
/// <param name="scriptName">The name of the script.</param>
|
|
/// <param name="code">The script code to compile.</param>
|
|
/// <returns>A <see cref="ScriptCompilationResult"/> containing the compiled script on success, or error messages on failure.</returns>
|
|
public ScriptCompilationResult Compile(string scriptName, string code)
|
|
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
|
|
|
|
/// <summary>
|
|
/// Compiles a bare C# boolean trigger expression against the restricted
|
|
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
|
|
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
|
|
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
|
|
/// options and forbidden-API trust validation as <see cref="Compile"/>.
|
|
/// </summary>
|
|
/// <param name="name">The name of the trigger expression.</param>
|
|
/// <param name="expression">The trigger expression to compile.</param>
|
|
/// <returns>A <see cref="ScriptCompilationResult"/> containing the compiled expression on success, or error messages on failure.</returns>
|
|
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
|
|
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
|
|
|
|
/// <summary>
|
|
/// Shared compilation path: validates the trust model, builds the script
|
|
/// against the given globals type, and returns the compiled result.
|
|
/// </summary>
|
|
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
|
|
{
|
|
// Validate trust model
|
|
var violations = ValidateTrustModel(code);
|
|
if (violations.Count > 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"Script {Script} failed trust validation: {Violations}",
|
|
name, string.Join("; ", violations));
|
|
return ScriptCompilationResult.Failed(violations);
|
|
}
|
|
|
|
try
|
|
{
|
|
var script = CSharpScript.Create<object?>(
|
|
code,
|
|
BuildScriptOptions(),
|
|
globalsType: globalsType);
|
|
|
|
var diagnostics = script.Compile();
|
|
var errors = diagnostics
|
|
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
|
.Select(d => d.GetMessage())
|
|
.ToList();
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"Script {Script} compilation failed: {Errors}",
|
|
name, string.Join("; ", errors));
|
|
return ScriptCompilationResult.Failed(errors);
|
|
}
|
|
|
|
_logger.LogDebug("Script {Script} compiled successfully", name);
|
|
return ScriptCompilationResult.Succeeded(script);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
|
|
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of script compilation, containing either the compiled script or error messages.
|
|
/// </summary>
|
|
public class ScriptCompilationResult
|
|
{
|
|
/// <summary>Indicates whether compilation succeeded.</summary>
|
|
public bool IsSuccess { get; }
|
|
/// <summary>The compiled script, or null if compilation failed.</summary>
|
|
public Script<object?>? CompiledScript { get; }
|
|
/// <summary>List of error messages, empty if compilation succeeded.</summary>
|
|
public IReadOnlyList<string> Errors { get; }
|
|
|
|
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
|
{
|
|
IsSuccess = success;
|
|
CompiledScript = script;
|
|
Errors = errors;
|
|
}
|
|
|
|
/// <summary>Creates a successful compilation result.</summary>
|
|
/// <param name="script">The compiled script.</param>
|
|
/// <returns>A <see cref="ScriptCompilationResult"/> with <see cref="IsSuccess"/> set to <c>true</c> and the compiled script attached.</returns>
|
|
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
|
new(true, script, []);
|
|
|
|
/// <summary>Creates a failed compilation result.</summary>
|
|
/// <param name="errors">List of error messages.</param>
|
|
/// <returns>A <see cref="ScriptCompilationResult"/> with <see cref="IsSuccess"/> set to <c>false</c> and the provided error messages.</returns>
|
|
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
|
|
new(false, null, errors);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
|
|
/// as the "Instance" global, and parameters are available via "Parameters".
|
|
/// </summary>
|
|
public class ScriptGlobals
|
|
{
|
|
/// <summary>The script runtime context providing access to instance state.</summary>
|
|
public ScriptRuntimeContext Instance { get; set; } = null!;
|
|
/// <summary>Script parameters passed by the caller.</summary>
|
|
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
|
/// <summary>Cancellation token for script execution.</summary>
|
|
public CancellationToken CancellationToken { get; set; }
|
|
|
|
/// <summary>
|
|
/// Alarm context when this script is invoked as an on-trigger handler.
|
|
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
|
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
|
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
|
/// </summary>
|
|
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
|
|
|
/// <summary>
|
|
/// Where this script sits in the composition tree. Defaults to root for
|
|
/// scripts on top-level templates; a flattened composed script gets
|
|
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
|
|
/// </summary>
|
|
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
|
|
Commons.Types.Scripts.ScriptScope.Root;
|
|
|
|
/// <summary>
|
|
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
|
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
|
/// </summary>
|
|
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
|
|
|
|
/// <summary>
|
|
/// Top-level Database access for scripts (delegates to Instance.Database).
|
|
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
|
|
/// </summary>
|
|
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
|
|
|
|
/// <summary>
|
|
/// Top-level Notify access for scripts (delegates to Instance.Notify).
|
|
/// Usage: Notify.To("listName").Send("subject", "message")
|
|
/// </summary>
|
|
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
|
|
|
|
/// <summary>
|
|
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
|
|
/// Usage: Scripts.CallShared("scriptName", params)
|
|
/// </summary>
|
|
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
|
|
|
/// <summary>
|
|
/// Read/write the current template's attributes by name. Resolves to the
|
|
/// canonical name for the script's scope, so a script on a composed
|
|
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
|
|
/// </summary>
|
|
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
|
|
|
|
/// <summary>
|
|
/// Indexed access to child compositions.
|
|
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
|
|
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
|
|
/// invokes a script on the child.
|
|
/// </summary>
|
|
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
|
|
|
|
/// <summary>
|
|
/// Parent composition (null when this script is on a root-level template).
|
|
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
|
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
|
|
/// </summary>
|
|
public CompositionAccessor? Parent =>
|
|
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
|
|
}
|