using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// WP-19: Script Trust Model tests — validates forbidden API detection and compilation. /// /// As of the M3.3 consolidation, ScriptCompilationService.ValidateTrustModel /// delegates its forbidden-API verdict to the shared authoritative /// ScriptAnalysis.ScriptTrustValidator, which is stricter than SiteRuntime's /// original deny-list: ALL of System.Net is forbidden (not just Sockets/Http), /// plus reflection gateways, dynamic, Activator, /// System.Runtime.InteropServices and Microsoft.Win32. Only /// System.Diagnostics.Process is blocked under System.Diagnostics — /// Stopwatch stays allowed. The real execution-path compile against /// ScriptGlobals / TriggerExpressionGlobals is unchanged. /// [Collection("SiteScriptCompileCache")] public class ScriptCompilationServiceTests { private readonly ScriptCompilationService _service; public ScriptCompilationServiceTests() { _service = new ScriptCompilationService(NullLogger.Instance); } [Fact] public void Compile_ValidScript_Succeeds() { var result = _service.Compile("test", "1 + 1"); Assert.True(result.IsSuccess); Assert.NotNull(result.CompiledScript); Assert.Empty(result.Errors); } [Fact] public void Compile_SameCodeTwice_SharesOneRoslynCompile() { SiteScriptCompileCache.Clear(); var r1 = _service.Compile("deploy-gate-copy", "return 1 + 1;"); var r2 = _service.Compile("prestart-copy", "return 1 + 1;"); Assert.True(r1.IsSuccess); Assert.Same(r1.CompiledScript, r2.CompiledScript); // one compile, shared Script (N4) — the definitive proof // Hits is a process-global counter; other test classes in this assembly compile scripts // concurrently (they are not in this serialized collection), so the exact post-Clear count // is not deterministic under a full-assembly parallel run. The shared-Script assertion // above is the real proof of cache reuse; here we only require the second lookup registered // a hit (>= 1) rather than pinning an exact global count. Assert.True(SiteScriptCompileCache.Hits >= 1); } [Fact] public void Compile_ScriptAndTriggerExpression_DoNotCrossContaminate() { SiteScriptCompileCache.Clear(); var script = _service.Compile("s", "1 > 0"); var trigger = _service.CompileTriggerExpression("t", "1 > 0"); Assert.NotSame(script.CompiledScript, trigger.CompiledScript); // different globals surfaces } [Fact] public void Compile_InvalidSyntax_ReturnsErrors() { var result = _service.Compile("bad", "this is not valid C# {{{"); Assert.False(result.IsSuccess); Assert.NotEmpty(result.Errors); } [Fact] public void ValidateTrustModel_SystemIO_Forbidden() { var violations = _service.ValidateTrustModel("System.IO.File.ReadAllText(\"test\")"); Assert.NotEmpty(violations); Assert.Contains(violations, v => v.Contains("System.IO")); } [Fact] public void ValidateTrustModel_Process_Forbidden() { var violations = _service.ValidateTrustModel( "System.Diagnostics.Process.Start(\"cmd\")"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_Reflection_Forbidden() { var violations = _service.ValidateTrustModel( "typeof(string).GetType().GetMethods(System.Reflection.BindingFlags.Public)"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_Sockets_Forbidden() { var violations = _service.ValidateTrustModel( "new System.Net.Sockets.TcpClient()"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_HttpClient_Forbidden() { var violations = _service.ValidateTrustModel( "new System.Net.Http.HttpClient()"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_AsyncAwait_Allowed() { // System.Threading.Tasks should be allowed (async/await support) var violations = _service.ValidateTrustModel( "await System.Threading.Tasks.Task.Delay(100)"); Assert.Empty(violations); } [Fact] public void ValidateTrustModel_CancellationToken_Allowed() { var violations = _service.ValidateTrustModel( "System.Threading.CancellationToken.None"); Assert.Empty(violations); } [Fact] public void ValidateTrustModel_CleanCode_NoViolations() { var code = @" var x = 1 + 2; var list = new List { 1, 2, 3 }; var sum = list.Sum(); sum"; var violations = _service.ValidateTrustModel(code); Assert.Empty(violations); } [Fact] public void Compile_ForbiddenApi_FailsValidation() { var result = _service.Compile("evil", "System.IO.File.Delete(\"/tmp/test\")"); Assert.False(result.IsSuccess); Assert.NotEmpty(result.Errors); } // ── M3.3: stricter shared-validator behavior ── [Fact] public void ValidateTrustModel_SystemNetDns_Forbidden() { // The shared validator forbids ALL of System.Net — not just Sockets/Http. // System.Net.Dns was allowed under the old SiteRuntime list; now blocked. var violations = _service.ValidateTrustModel( "System.Net.Dns.GetHostName()"); Assert.NotEmpty(violations); Assert.Contains(violations, v => v.Contains("System.Net")); } [Fact] public void ValidateTrustModel_ReflectionGatewayViaPermittedType_Forbidden() { // typeof(x).Assembly.GetType(...) never spells a forbidden namespace, but // the shared validator rejects the reflection-gateway members regardless of // receiver — this was NOT caught by the old SiteRuntime list. var violations = _service.ValidateTrustModel( "typeof(string).Assembly.GetType(\"System.IO.File\")"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_Dynamic_Forbidden() { var violations = _service.ValidateTrustModel("dynamic d = 1; return d;"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_Activator_Forbidden() { var violations = _service.ValidateTrustModel( "Activator.CreateInstance(typeof(string))"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_InteropServices_Forbidden() { var violations = _service.ValidateTrustModel( "System.Runtime.InteropServices.Marshal.SizeOf()"); Assert.NotEmpty(violations); } [Fact] public void ValidateTrustModel_Stopwatch_Allowed() { // Only System.Diagnostics.Process is blocked under System.Diagnostics — // Stopwatch stays allowed. var violations = _service.ValidateTrustModel( "var sw = System.Diagnostics.Stopwatch.StartNew(); return sw.ElapsedMilliseconds;"); Assert.Empty(violations); } /// /// Native-memory leak guard. ScriptOptions.WithReferences(Assembly[]) resolves each /// assembly through MetadataReference.CreateFromFile, and every such reference owns an /// AssemblyMetadataPEReaderNativeHeapMemoryBlock — an unmanaged copy /// of the assembly metadata that nothing here ever disposes. Building the options per compile /// therefore grows native memory permanently: no GC reclaims it, and it is invisible to /// GC.GetTotalAllocatedBytes and to gcdump. /// /// /// Diagnosed from a live dump of the wonder-app-vd03 Site node (2026-08-12): 2,885 MB working /// set 78 min after a cold start, of which only 150 MB was live GC heap; VMMap attributed /// 2,469 MB to the default process heap and dumpheap -stat found 6,740 each of /// AssemblyMetadata / PEReader / MetadataImageReference against just 473 /// DLLs on disk — i.e. ~1,348 undisposed copies of this service's 5-assembly reference set. /// /// /// /// Asserted on the artifact rather than on memory: a watch-the-bytes test would be flaky, and /// the leak is native so the managed allocation counters cannot see it at all. Two DISTINCT /// bodies are required — identical ones would be served from /// without a second CompileUncached, and the test /// would pass without proving anything. /// /// [Fact] public void Compile_DistinctScripts_ShareOneMetadataReferenceSet_SoNativeMemoryDoesNotGrow() { SiteScriptCompileCache.Clear(); var first = _service.Compile("first", "return 1 + 1;"); var second = _service.Compile("second", "return 2 + 2;"); Assert.True(first.IsSuccess); Assert.True(second.IsSuccess); Assert.NotSame(first.CompiledScript, second.CompiledScript); // two real compiles, not a cache hit var firstRefs = first.CompiledScript!.Options.MetadataReferences; var secondRefs = second.CompiledScript!.Options.MetadataReferences; Assert.NotEmpty(firstRefs); // else the reference-equality checks below are vacuous Assert.Equal(firstRefs.Length, secondRefs.Length); for (var i = 0; i < firstRefs.Length; i++) Assert.Same(firstRefs[i], secondRefs[i]); // The options object itself is cached, so it must not be rebuilt either. Assert.Same(first.CompiledScript.Options, second.CompiledScript.Options); } [Fact] public void Compile_UsesProcessWideCachingMetadataResolver() { SiteScriptCompileCache.Clear(); var result = _service.Compile("resolver-pin", "return 41 + 1;"); Assert.True(result.IsSuccess); // Without the shared caching resolver, EVERY compile re-resolves the // transitive assembly closure via MetadataReference.CreateFromFile — // ~74+ fresh native metadata copies per compiled script (2026-08-12 dump). Assert.Same( ZB.MOM.WW.ScadaBridge.ScriptAnalysis.CachingScriptMetadataResolver.Instance, result.CompiledScript!.Options.MetadataResolver); } }