using Microsoft.CodeAnalysis.Scripting; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API /// (HttpClient / File / Process / reflection) fails at compile, not at evaluation. /// Locks decision #6 — scripts can't escape to the broader .NET surface. /// [Trait("Category", "Unit")] public sealed class ScriptSandboxTests { [Fact] public void Happy_path_script_compiles_and_returns() { // Baseline — ctx + Math + basic types must work. var evaluator = ScriptEvaluator.Compile( """ var v = (double)ctx.GetTag("X").Value; return Math.Abs(v) * 2.0; """); evaluator.ShouldNotBeNull(); } [Fact] public async Task Happy_path_script_runs_and_reads_seeded_tag() { var evaluator = ScriptEvaluator.Compile( """return (double)ctx.GetTag("In").Value * 2.0;"""); var ctx = new FakeScriptContext().Seed("In", 21.0); var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); result.ShouldBe(42.0); } [Fact] public async Task SetVirtualTag_records_the_write() { var evaluator = ScriptEvaluator.Compile( """ ctx.SetVirtualTag("Out", 42); return 0; """); var ctx = new FakeScriptContext(); await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); ctx.Writes.Count.ShouldBe(1); ctx.Writes[0].Path.ShouldBe("Out"); ctx.Writes[0].Value.ShouldBe(42); } [Fact] public void Rejects_File_IO_at_compile() { Should.Throw(() => ScriptEvaluator.Compile( """return System.IO.File.ReadAllText("c:/secrets.txt");""")); } [Fact] public void Rejects_HttpClient_at_compile() { Should.Throw(() => ScriptEvaluator.Compile( """ var c = new System.Net.Http.HttpClient(); return 0; """)); } [Fact] public void Rejects_Process_Start_at_compile() { Should.Throw(() => ScriptEvaluator.Compile( """ System.Diagnostics.Process.Start("cmd.exe"); return 0; """)); } [Fact] public void Rejects_Reflection_Assembly_Load_at_compile() { Should.Throw(() => ScriptEvaluator.Compile( """ System.Reflection.Assembly.Load("System.Core"); return 0; """)); } [Fact] public void Rejects_Environment_Exit_at_compile() { // System.Environment lives in System.Private.CoreLib (allow-listed for // primitives) so a namespace-prefix deny-list cannot block it. Environment.Exit // terminates the whole in-process OPC UA server — every connected client and // every driver — so it MUST be rejected member-granularly. (Core.Scripting-001.) Should.Throw(() => ScriptEvaluator.Compile( """ System.Environment.Exit(0); return 0; """)); } [Fact] public void Rejects_Environment_FailFast_at_compile() { // Environment.FailFast crashes the host process immediately — same outage as // Exit. (Core.Scripting-001.) Should.Throw(() => ScriptEvaluator.Compile( """ System.Environment.FailFast("boom"); return 0; """)); } [Fact] public void Rejects_AppDomain_at_compile() { // AppDomain.CurrentDomain exposes process-wide control (assembly load events, // unhandled-exception hooks). Not script surface. (Core.Scripting-001.) Should.Throw(() => ScriptEvaluator.Compile( """ var n = System.AppDomain.CurrentDomain.FriendlyName; return 0; """)); } [Fact] public void Rejects_GC_Collect_at_compile() { // GC.Collect / GC.AddMemoryPressure let a script perturb the whole process's // memory subsystem. Not script surface. (Core.Scripting-001.) Should.Throw(() => ScriptEvaluator.Compile( """ System.GC.Collect(); return 0; """)); } [Fact] public void Rejects_Activator_CreateInstance_at_compile() { // Activator.CreateInstance is a reflection-equivalent escape — it can construct // a forbidden type by name without ever naming it syntactically. (Core.Scripting-001.) Should.Throw(() => ScriptEvaluator.Compile( """ var o = System.Activator.CreateInstance(typeof(object)); return 0; """)); } [Fact] public void Rejects_Environment_GetEnvironmentVariable_at_compile() { // The whole System.Environment type is forbidden (Core.Scripting-001) — even the // read-only GetEnvironmentVariable member. Once Exit / FailFast made the type // dangerous, the cleanest member-granular rule is to deny the type outright; the // read path has no legitimate use in a SCADA predicate either. Should.Throw(() => ScriptEvaluator.Compile( """return System.Environment.GetEnvironmentVariable("PATH");""")); } // --- Core.Scripting-002: type-reference node forms that bypassed the old walker --- // The old walker only inspected ObjectCreation / Invocation-with-member-access / // MemberAccess / bare-Identifier nodes. typeof, generic type arguments, casts, // is/as patterns, default(T), array element types, and declared variable types all // name a forbidden type without producing any of those nodes. The broadened walker // resolves GetTypeInfo on every TypeSyntax / ExpressionSyntax so they are all caught. [Fact] public void Rejects_typeof_forbidden_type_at_compile() { // Phase 7 plan A.6 explicitly calls out typeof as a sandbox-escape that must // fail at compile. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """return typeof(System.IO.File).Name;""")); } [Fact] public void Rejects_generic_type_argument_forbidden_type_at_compile() { // new List() — the forbidden type is nested inside an // allowed generic. The walker unwraps type arguments recursively. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ var l = new System.Collections.Generic.List(); return l.Count; """)); } [Fact] public void Rejects_cast_to_forbidden_type_at_compile() { // (System.IO.Stream)null — a cast expression names the type without invoking // or constructing it. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ var s = (System.IO.Stream)null; return 0; """)); } [Fact] public void Rejects_default_of_forbidden_type_at_compile() { // default(System.Reflection.Assembly) names a forbidden type via the default // operator. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ var a = default(System.Reflection.Assembly); return 0; """)); } [Fact] public void Rejects_is_pattern_forbidden_type_at_compile() { // 'o is System.IO.FileStream' names a forbidden type as the pattern's type // operand. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ object o = ctx.GetTag("X").Value; return o is System.IO.FileStream; """)); } [Fact] public void Rejects_as_expression_forbidden_type_at_compile() { // 'o as System.IO.Stream' names a forbidden type via the as operator. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ object o = ctx.GetTag("X").Value; var s = o as System.IO.Stream; return 0; """)); } [Fact] public void Rejects_array_creation_forbidden_element_type_at_compile() { // new System.IO.FileInfo[0] — the forbidden type is the array element type. // (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ var a = new System.IO.FileInfo[0]; return a.Length; """)); } [Fact] public void Rejects_local_declared_variable_forbidden_type_at_compile() { // An explicitly-typed local declaration 'System.Net.Http.HttpClient c;' names // a forbidden type with no construction or call. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ System.Net.Http.HttpClient c = null; return 0; """)); } [Fact] public void Rejects_typeof_forbidden_type_inside_Activator_at_compile() { // Activator is already denied by ForbiddenFullTypeNames, but the typeof operand // is independently a forbidden-type reference — confirm the typeof path catches // it even when the Activator type itself were allowed. (Core.Scripting-002.) Should.Throw(() => ScriptEvaluator.Compile( """ var t = typeof(System.Diagnostics.Process); return 0; """)); } [Fact] public async Task Allowed_generic_type_argument_still_compiles() { // Guard against over-blocking: a generic with only allow-listed type arguments // (List) must still compile and run. (Core.Scripting-002.) var evaluator = ScriptEvaluator.Compile( """ var l = new System.Collections.Generic.List { 1, 2, 3 }; return l.Count; """); var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken); result.ShouldBe(3); } [Fact] public async Task Allowed_typeof_still_compiles() { // typeof against an allow-listed type (int) must not be rejected. (Core.Scripting-002.) var evaluator = ScriptEvaluator.Compile( """return typeof(int).Name;"""); var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken); result.ShouldBe("Int32"); } [Fact] public async Task Script_exception_propagates_unwrapped() { var evaluator = ScriptEvaluator.Compile( """throw new InvalidOperationException("boom");"""); await Should.ThrowAsync(async () => await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken)); } [Fact] public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock() { // Scripts that need a timestamp go through ctx.Now so tests can pin it. var evaluator = ScriptEvaluator.Compile("""return ctx.Now;"""); evaluator.ShouldNotBeNull(); } [Fact] public void Deadband_helper_is_reachable_from_scripts() { var evaluator = ScriptEvaluator.Compile( """return ScriptContext.Deadband(10.5, 10.0, 0.3);"""); evaluator.ShouldNotBeNull(); } [Fact] public async Task Linq_Enumerable_is_available_from_scripts() { // LINQ is in the allow-list because SCADA math frequently wants Sum / Average // / Where. Confirm it works. var evaluator = ScriptEvaluator.Compile( """ var nums = new[] { 1, 2, 3, 4, 5 }; return nums.Where(n => n > 2).Sum(); """); var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken); result.ShouldBe(12); } [Fact] public async Task DataValueSnapshot_is_usable_in_scripts() { // ctx.GetTag returns DataValueSnapshot so scripts branch on quality. var evaluator = ScriptEvaluator.Compile( """ var v = ctx.GetTag("T"); return v.StatusCode == 0; """); var ctx = new FakeScriptContext().Seed("T", 5.0); var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken); result.ShouldBeTrue(); } [Fact] public void Compile_error_gives_location_in_diagnostics() { // Compile errors must carry the source span so the Admin UI can point at them. try { ScriptEvaluator.Compile("""return fooBarBaz + 1;"""); Assert.Fail("expected CompilationErrorException"); } catch (CompilationErrorException ex) { ex.Diagnostics.ShouldNotBeEmpty(); ex.Diagnostics[0].Location.ShouldNotBeNull(); } } }