using System.Reflection; using System.Runtime.Loader; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; /// /// Compiles + runs user scripts against a subclass. Core /// evaluator — no caching, no timeout, no logging side-effects (those land in /// , /// , and /// respectively). /// /// /// /// Scripts are wrapped in a synthesized CompiledScript.Run(globals) method /// and compiled via into a regular .NET assembly /// that is loaded into a collectible /// . The collectible ALC is the fix for /// Core.Scripting-008: per-publish recompile accretion was previously unbounded /// because Roslyn's CSharpScript.CreateDelegate emits into the default ALC /// (non-collectible); now unloads the entire ALC and the /// emitted assembly becomes eligible for GC. /// /// /// Compile pipeline is a three-step gate, unchanged in intent from the legacy /// CSharpScript path: (1) Roslyn parse + compile against the /// allow-list — catches syntax errors, unresolved /// types (the sandbox's first line of defense), and most type-resolution /// failures, throwing ; (2) /// runs against the semantic model — catches /// sandbox escapes that slipped past reference restrictions due to .NET's type /// forwarding, throwing ; (3) emit /// to an in-memory PE stream + load into the collectible ALC — throws at this /// layer only for internal Roslyn bugs, not user error. /// /// /// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag /// engine catches them per-tag and maps to BadInternalError quality /// per Phase 7 decision #11; this layer doesn't swallow anything so tests can /// assert on the original exception type. /// /// /// Scripts are expected to be statement bodies that end with an explicit /// return …; — the wrapper provides only the surrounding method body, so /// the script's final-expression-yields-result behavior of legacy /// CSharpScript is replaced by ordinary C# method semantics. Every script /// in the existing test corpus already uses explicit return; this is a /// documented authoring convention. /// /// public sealed class ScriptEvaluator : IDisposable where TContext : ScriptContext { private readonly ScriptAssemblyLoadContext _alc; private readonly Func, TResult> _func; private bool _disposed; private ScriptEvaluator(ScriptAssemblyLoadContext alc, Func, TResult> func) { _alc = alc; _func = func; } /// Compiles user script source into an evaluator. /// The user script source code to compile. public static ScriptEvaluator Compile(string scriptSource) { if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource)); var sandbox = ScriptSandbox.Build(typeof(TContext)); // Step 1 — synthesize a wrapper class around the script body and parse it. The // wrapper's `Run` method is what we invoke at runtime; the user's source is // pasted in as its body so explicit `return` semantics apply. var wrapperSource = BuildWrapperSource(scriptSource, sandbox.Imports); var syntaxTree = CSharpSyntaxTree.ParseText(wrapperSource); // Step 1a — defend against wrapper-source injection (Core.Scripting-013). // A script body of `return 0; } public static int Evil() { return 0;` would // close the synthesized `Run` method early, declare a sibling `Evil` method // inside the synthesized `CompiledScript` class, and leave the wrapper's // trailing `}` balanced. ForbiddenTypeAnalyzer still walks the injected // members so this isn't a direct sandbox escape, but it relaxes the // documented "method body" authoring contract and widens the analyzer's // surface. Reject by requiring that the parsed `CompiledScript` class // contains exactly one member declaration (the `Run` method). EnforceSingleRunMember(syntaxTree); // Step 2 — Roslyn compile against the sandbox allow-list. Anything not in the // references set is unresolved and produces a compiler error. var assemblyName = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled." + Guid.NewGuid().ToString("N"); var compileOptions = new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, allowUnsafe: false, // Don't generate XML doc warnings for the synthesized wrapper. warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable); var compilation = CSharpCompilation.Create( assemblyName, syntaxTrees: new[] { syntaxTree }, references: sandbox.References, options: compileOptions); var compileDiagnostics = compilation.GetDiagnostics(); var compileErrors = compileDiagnostics .Where(d => d.Severity == DiagnosticSeverity.Error) .ToArray(); if (compileErrors.Length > 0) throw new CompilationErrorException(compileErrors); // Step 3 — forbidden-type semantic analysis. Defense-in-depth against // reference-list leaks due to type forwarding. var rejections = ForbiddenTypeAnalyzer.Analyze(compilation); if (rejections.Count > 0) throw new ScriptSandboxViolationException(rejections); // Step 4 — emit to an in-memory PE stream and load into a collectible ALC. using var peStream = new MemoryStream(); var emitResult = compilation.Emit(peStream); if (!emitResult.Success) { var emitErrors = emitResult.Diagnostics .Where(d => d.Severity == DiagnosticSeverity.Error) .ToArray(); throw new CompilationErrorException(emitErrors); } peStream.Position = 0; var alc = new ScriptAssemblyLoadContext(assemblyName); Assembly assembly; try { assembly = alc.LoadFromStream(peStream); } catch { // Failed to load — drop the ALC so we don't leak a half-initialised one. alc.Unload(); throw; } // Step 5 — resolve the wrapper's Run method and bind a typed delegate. The // wrapper source above puts the type in this exact namespace + class — keep the // names in sync with BuildWrapperSource. Func, TResult> func; try { var wrapperType = assembly.GetType( "ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled.CompiledScript", throwOnError: true)!; var runMethod = wrapperType.GetMethod( "Run", BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException( "Synthesized wrapper is missing the public static Run method."); func = (Func, TResult>)Delegate.CreateDelegate( typeof(Func, TResult>), runMethod); } catch { alc.Unload(); throw; } return new ScriptEvaluator(alc, func); } /// Runs the script against an already-constructed context. /// The script context. /// Cancellation token for the operation. public Task RunAsync(TContext context, CancellationToken ct = default) { if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator)); if (context is null) throw new ArgumentNullException(nameof(context)); ct.ThrowIfCancellationRequested(); var globals = new ScriptGlobals { ctx = context }; // The user's script is synchronous (Roslyn emits a static method that returns // TResult directly). We surface a Task only to keep the existing // RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in // Task.Run so a long-running script still honours its wall-clock budget. var result = _func(globals); return Task.FromResult(result); } /// /// Unload the collectible that holds the emitted /// script assembly so the runtime can reclaim it. After disposal the evaluator can /// no longer be invoked — call /// again for a fresh one. Dispose is idempotent. /// /// /// Unload is eligible-for-collection, not synchronous: the assembly is /// reclaimed when the GC determines no live references remain. The cache disposes /// evaluators in so a /// config-generation publish releases the prior generation in one sweep; the /// reclaim then races with the next GC cycle. Tests verify the reclaim via /// + . /// public void Dispose() { if (_disposed) return; _disposed = true; _alc.Unload(); } /// /// Reject scripts whose source contains brace-balanced injections that would /// declare sibling members alongside the synthesized CompiledScript.Run /// method. The expected shape is a single CompiledScript class with /// exactly one member — the Run method. Anything else (a sibling /// method, nested class, additional class in the namespace, free-floating /// top-level statement) means the user source closed the synthesized braces /// early and injected its own declarations. (Core.Scripting-013.) /// private static void EnforceSingleRunMember(SyntaxTree syntaxTree) { var root = syntaxTree.GetCompilationUnitRoot(); // The compilation unit must hold exactly one type declaration — our // CompiledScript. Anything else means the user closed the synthesized // namespace or class early and injected another type declaration. var typeMembers = root.DescendantNodes() .OfType() .ToArray(); if (typeMembers.Length != 1 || typeMembers[0].Identifier.ValueText != "CompiledScript") { throw new CompilationErrorException(new[] { Diagnostic.Create( new DiagnosticDescriptor( id: "LMX001", title: "Script wrapper injection", messageFormat: "Script source must be a statement body. Declarations of " + "additional types alongside the wrapper's CompiledScript class " + "are not allowed; check for unbalanced braces or stray " + "`class` / `namespace` keywords in the source. (Core.Scripting-013)", category: "Sandbox", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true), typeMembers.Length > 1 ? typeMembers[1].Identifier.GetLocation() : Location.None), }); } // The CompiledScript class itself must contain exactly one member — the Run // method. A second member means the user closed Run early and started a sibling. var classMembers = ((Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax)typeMembers[0]).Members; if (classMembers.Count != 1 || classMembers[0] is not Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax m || m.Identifier.ValueText != "Run") { throw new CompilationErrorException(new[] { Diagnostic.Create( new DiagnosticDescriptor( id: "LMX002", title: "Script wrapper injection", messageFormat: "Script source must be a statement body. Declarations of " + "sibling members (methods, properties, nested types) alongside " + "the wrapper's Run method are not allowed; check for unbalanced " + "braces or a stray `}` followed by a `public`/`private`/`static` " + "declaration in the source. (Core.Scripting-013)", category: "Sandbox", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true), classMembers.Count > 1 ? classMembers[1].GetLocation() : Location.None), }); } } /// /// Synthesize the source we hand to Roslyn. The user's script body is pasted /// verbatim inside CompiledScript.Run; the using directives mirror /// 's imports so scripts can write Math.Abs /// instead of System.Math.Abs. /// private static string BuildWrapperSource(string userSource, IReadOnlyList imports) { var sb = new StringBuilder(); foreach (var import in imports) sb.Append("using ").Append(import).AppendLine(";"); sb.AppendLine(); sb.AppendLine("namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;"); sb.AppendLine(); sb.AppendLine("public static class CompiledScript"); sb.AppendLine("{"); sb.Append(" public static ").Append(ToCSharpTypeName(typeof(TResult))) .Append(" Run(").Append(ToCSharpTypeName(typeof(ScriptGlobals))) .AppendLine(" globals)"); sb.AppendLine(" {"); sb.AppendLine(" var ctx = globals.ctx;"); // User source ends with `return X;` per the authoring convention; we paste it // verbatim. The leading newline keeps Roslyn diagnostics' line numbers usable // by operators (errors point at the user's source line, not the wrapper). sb.AppendLine("#line 1"); sb.AppendLine(userSource); sb.AppendLine(" }"); sb.AppendLine("}"); return sb.ToString(); } /// /// Convert a runtime to a C# type-name string suitable for /// emitting into Roslyn source. Uses global::-qualified FQNs to avoid /// accidental capture by the wrapper's using directives, handles nested /// types (+.), and recurses for generic arguments so the /// ScriptGlobals<TContext> parameter is emitted correctly. /// private static string ToCSharpTypeName(Type t) { if (t == typeof(void)) return "void"; // Primitive aliases keep the synthesized source readable when diagnostic // logging dumps it; functionally identical to the FQN form. if (t == typeof(bool)) return "bool"; if (t == typeof(byte)) return "byte"; if (t == typeof(sbyte)) return "sbyte"; if (t == typeof(short)) return "short"; if (t == typeof(ushort)) return "ushort"; if (t == typeof(int)) return "int"; if (t == typeof(uint)) return "uint"; if (t == typeof(long)) return "long"; if (t == typeof(ulong)) return "ulong"; if (t == typeof(float)) return "float"; if (t == typeof(double)) return "double"; if (t == typeof(decimal)) return "decimal"; if (t == typeof(string)) return "string"; if (t == typeof(object)) return "object"; if (Nullable.GetUnderlyingType(t) is { } inner) return ToCSharpTypeName(inner) + "?"; if (t.IsArray) return ToCSharpTypeName(t.GetElementType()!) + "[]"; if (t.IsGenericType) { // Walk the FullName by '.' segments (after '+ → .'). For each segment // ending with `Name\`N`, consume N generic arguments and emit them as // `<…>` on that segment. Nested generic-in-generic (Outer.Inner) // emits as `global::Ns.Outer.Inner` — valid C#. The pre-fix code // used `IndexOf('`')` to find the FIRST backtick and truncated the // entire name there, silently dropping the rest of the nested-generic // closed args. (Core.Scripting-015.) var rawName = t.GetGenericTypeDefinition().FullName!.Replace('+', '.'); var allArgs = t.GetGenericArguments(); var segments = rawName.Split('.'); var argIndex = 0; var sb = new StringBuilder("global::"); for (int i = 0; i < segments.Length; i++) { if (i > 0) sb.Append('.'); var seg = segments[i]; var backtick = seg.IndexOf('`'); if (backtick >= 0) { var arity = int.Parse(seg.AsSpan(backtick + 1)); sb.Append(seg, 0, backtick); sb.Append('<'); for (int j = 0; j < arity; j++) { if (j > 0) sb.Append(", "); sb.Append(ToCSharpTypeName(allArgs[argIndex++])); } sb.Append('>'); } else { sb.Append(seg); } } return sb.ToString(); } return "global::" + t.FullName!.Replace('+', '.'); } } /// /// Collectible that hosts a single emitted script /// assembly. Created per instance so /// releases exactly that script. Resolves /// dependencies via the default ALC — script assemblies reference the BCL + the /// application's own types, all of which live in the default context. /// internal sealed class ScriptAssemblyLoadContext : AssemblyLoadContext { /// Initializes a new instance of the class. /// The name of the assembly load context. public ScriptAssemblyLoadContext(string name) : base(name, isCollectible: true) { } /// protected override Assembly? Load(AssemblyName assemblyName) => null; } /// /// Thrown by when Roslyn /// reports compile-time errors against the wrapper source. Mirrors the /// Microsoft.CodeAnalysis.Scripting.CompilationErrorException from the legacy /// CSharpScript path so callers (engines + the Admin test-harness) keep the /// same catch site after the Core.Scripting-008 rewrite. /// public sealed class CompilationErrorException : Exception { /// Gets the compilation diagnostics that caused the error. public IReadOnlyList Diagnostics { get; } /// Initializes a new instance of the class. /// The compilation diagnostics that caused the error. public CompilationErrorException(IReadOnlyList diagnostics) : base(BuildMessage(diagnostics)) { Diagnostics = diagnostics; } private static string BuildMessage(IReadOnlyList diagnostics) { if (diagnostics.Count == 0) return "Script compile failed."; // Operators see this — match the legacy Roslyn format ("(line,col): error CSxxxx: // message") so existing operator runbooks still match. var first = diagnostics[0]; var rest = diagnostics.Count == 1 ? "" : $" (and {diagnostics.Count - 1} more)"; return first.ToString() + rest; } }