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