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; /// /// Script Trust Model — compiles C# scripts using Roslyn with restricted API access. /// The forbidden-API verdict is delegated to the shared authoritative /// ; this service keeps the real /// execution-path compile of the script against / /// . /// /// Compile results — success AND failure — are memoised in this service's /// (keyed on code hash + globals type), so an /// unchanged script body compiles once per process lifetime and the deploy gate, /// the Instance Actor's PreStart recompile, and SharedScriptLibrary all reuse one /// compiled Script<object?>. Error text is name-free by construction, so a /// cached failure renders correctly for every caller. /// /// The cache is owned per service instance, not static (register row 100). /// "Once per process" is a property of this service being registered as a DI /// singleton (, pinned by /// SiteRuntimeServiceRegistrationTests) — the whole site reaches the cache through /// that one instance, which the Deployment Manager threads into its /// and into every Instance Actor. Making the /// cache an instance field rather than static state means a test (or a second host in one /// process) that constructs its own service gets its own cache instead of mutating everyone /// else's; that shared-static cross-talk is what row 100 closed. /// public class ScriptCompilationService { private readonly ILogger _logger; private readonly SiteScriptCompileCache _compileCache; /// Initializes a new instance of the ScriptCompilationService class. /// Logger instance. public ScriptCompilationService(ILogger logger) : this(logger, new SiteScriptCompileCache()) { } /// /// Test seam: constructs the service over a caller-supplied compile cache, so a test can /// observe / /// for exactly the compiles it caused. Production always uses the public constructor. /// /// Logger instance. /// The compile cache this service memoises into. internal ScriptCompilationService( ILogger logger, SiteScriptCompileCache compileCache) { _logger = logger; _compileCache = compileCache; } /// /// Validates that the script does not reference forbidden APIs. /// /// As of the script-analysis consolidation this delegates to the shared /// authoritative , /// which is the same Roslyn semantic-symbol analysis this service previously hosted /// plus reflection-gateway / dynamic / Activator 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 . /// /// Returns a list of violation messages, empty if clean. /// /// The script code to validate. /// A list of trust-model violation messages; empty if the script is clean. public IReadOnlyList ValidateTrustModel(string code) => ScriptTrustValidator.FindViolations(code); /// /// Assemblies referenced by compiled scripts, used to build the Roslyn scripting /// options for the real execution-path compile. /// 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 ]; /// /// Shared Roslyn scripting options (references + imports) used by both full /// script compilation and trigger-expression compilation. /// /// /// Built ONCE, deliberately. Do not turn this back into a method. /// WithReferences(Assembly[]) resolves every assembly through /// MetadataReference.CreateFromFile, which does not cache: each call /// mints a fresh MetadataReference owning an AssemblyMetadata → /// PEReader → NativeHeapMemoryBlock, an unmanaged copy of the /// assembly metadata that nothing disposes. Building the options per compile /// therefore leaks native memory permanently — no GC reclaims it, and neither /// gcdump nor GC.GetTotalAllocatedBytes can see it, because it is not /// on the managed heap. /// /// /// /// This was a live defect: a Site node reached a 2,885 MB working set with only /// 150 MB of live GC heap, ~2,469 MB of it on the default process heap across /// ~6,700 undisposed AssemblyMetadata instances (against 473 DLLs on /// disk). The method form reads as harmless, which is exactly why it survived — /// see ScriptCompilationServiceTests.Compile_DistinctScripts_ShareOneMetadataReferenceSet_SoNativeMemoryDoesNotGrow, /// which pins reference-equality of the reference set across compiles. /// ScriptAnalysisService.DefaultOptions and /// ScriptTrustPolicy.DefaultReferences already follow this pattern. /// /// /// /// Static options alone are NOT enough: every script.Compile() binds the /// transitive closure of these references, and Roslyn's default resolver /// re-resolves that closure through MetadataReference.CreateFromFile on /// EVERY compile (~74–105 fresh native metadata copies per compiled script, /// confirmed live post-5a781c70 on 2026-08-12: 21 scripts held 6,640 /// AssemblyMetadata/PEReader/MetadataImageReference objects). The shared /// memoizes those resolutions /// process-wide; see its doc for the full mechanism. /// /// private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default .WithReferences(ScriptAssemblies) .WithMetadataResolver(CachingScriptMetadataResolver.Instance) .WithImports( "System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); /// /// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext /// and parameters dictionary, and returns an object? result. /// /// The name of the script. /// The script code to compile. /// A containing the compiled script on success, or error messages on failure. public ScriptCompilationResult Compile(string scriptName, string code) => CompileCore(scriptName, code, typeof(ScriptGlobals)); /// /// Compiles a bare C# boolean trigger expression against the restricted /// read-only . The expression is a /// trailing expression (no return); Roslyn scripting yields its /// value, which the caller coerces to bool. Reuses the same script /// options and forbidden-API trust validation as . /// /// The name of the trigger expression. /// The trigger expression to compile. /// A containing the compiled expression on success, or error messages on failure. public ScriptCompilationResult CompileTriggerExpression(string name, string expression) => CompileCore(name, expression, typeof(TriggerExpressionGlobals)); /// /// Shared compilation funnel for every site-side compile (the deploy gate, the /// Instance Actor's PreStart recompile, and SharedScriptLibrary). Results — success /// AND failure — are memoised in this service's , /// keyed on code hash + globals type: an unchanged script body compiles once per /// process lifetime (this service is a DI singleton) and the compiled /// Script<object?> is shared across all call sites (N4). Cached error text is /// name-free by construction (Roslyn diagnostics / the compile-exception message never /// embed the caller's script name), so a cached failure renders correctly for every caller. /// private ScriptCompilationResult CompileCore(string name, string code, Type globalsType) { var result = _compileCache.GetOrAdd(code, globalsType, () => CompileUncached(name, code, globalsType)); if (!result.IsSuccess) _logger.LogDebug( "Script {Script}: returning cached compile failure ({ErrorCount} error(s))", name, result.Errors.Count); // miss-path logs name the FIRST caller; keep this caller visible return result; } /// /// The uncached compile: validates the trust model, builds the script against the /// given globals type, and returns the compiled result. The verdict is deterministic /// on the code (plus the compile-time-static trust policy and globals surface), so it /// is safe to cache alongside the compiled script. /// private ScriptCompilationResult CompileUncached(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( code, SharedScriptOptions, 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}"]); } } } /// /// Result of script compilation, containing either the compiled script or error messages. /// public class ScriptCompilationResult { /// Indicates whether compilation succeeded. public bool IsSuccess { get; } /// The compiled script, or null if compilation failed. public Script? CompiledScript { get; } /// List of error messages, empty if compilation succeeded. public IReadOnlyList Errors { get; } private ScriptCompilationResult(bool success, Script? script, IReadOnlyList errors) { IsSuccess = success; CompiledScript = script; Errors = errors; } /// Creates a successful compilation result. /// The compiled script. /// A with set to true and the compiled script attached. public static ScriptCompilationResult Succeeded(Script script) => new(true, script, []); /// Creates a failed compilation result. /// List of error messages. /// A with set to false and the provided error messages. public static ScriptCompilationResult Failed(IReadOnlyList errors) => new(false, null, errors); } /// /// Global variables available to compiled scripts. The ScriptRuntimeContext is injected /// as the "Instance" global, and parameters are available via "Parameters". /// public class ScriptGlobals { /// The script runtime context providing access to instance state. public ScriptRuntimeContext Instance { get; set; } = null!; /// Script parameters passed by the caller. public ScriptParameters Parameters { get; set; } = new ScriptParameters(); /// Cancellation token for script execution. public CancellationToken CancellationToken { get; set; } /// /// 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. /// public Commons.Types.Scripts.AlarmContext? Alarm { get; set; } /// /// 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. /// public Commons.Types.Scripts.ScriptScope Scope { get; set; } = Commons.Types.Scripts.ScriptScope.Root; /// /// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem). /// Usage: ExternalSystem.Call("systemName", "methodName", params) /// public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem; /// /// Top-level Database access for scripts (delegates to Instance.Database). /// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params) /// public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database; /// /// Top-level Notify access for scripts (delegates to Instance.Notify). /// Usage: Notify.To("listName").Send("subject", "message") /// public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify; /// /// Top-level Scripts access for shared script calls (delegates to Instance.Scripts). /// Usage: Scripts.CallShared("scriptName", params) /// public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts; /// /// 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 Attributes["Temperature"]. /// public AttributeAccessor Attributes => new(Instance, Scope.SelfPath); /// /// Indexed access to child compositions. /// Children["TempSensor"].Attributes["Temperature"] reads the /// composed child's attribute. Children["TempSensor"].CallScript("Sample") /// invokes a script on the child. /// public ChildrenAccessor Children => new(Instance, Scope.SelfPath); /// /// Read-only view of the instance's CURRENT alarm conditions — computed alarms /// and mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. /// Usage: var alarms = await Alarms.CurrentAsync(); /// /// /// Unlike the global — which exists only inside an on-trigger /// handler and describes the ONE alarm that fired — this is available to every script /// and describes the whole instance. Not scope-prefixed: alarm identity is not a /// scope-relative attribute name, so every scope sees the same list. /// /// public AlarmsAccessor Alarms => Instance.Alarms; /// Leveled per-run logging (design 2026-09-09). Usage: Log.Info("…"), Log.Warn("…", ex). Always reaches the node log; becomes Flow Debugger trace rows only for a traced run. public ScriptLogger Log => Instance.Log; /// /// Parent composition (null when this script is on a root-level template). /// Parent.Attributes["SpeedRPM"] reaches the parent's attribute; /// Parent.CallScript("Trip") invokes a parent script. /// public CompositionAccessor? Parent => Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath); }