5a781c706c
ScriptOptions.WithReferences(Assembly[]) resolves each assembly through MetadataReference.CreateFromFile, which does not cache: every call mints a fresh AssemblyMetadata -> PEReader -> NativeHeapMemoryBlock holding an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile therefore leaked native memory permanently — invisible to the GC, to gcdump and to the managed allocation counters, so the working set grew while the GC heap did not. Diagnosed from a live dump of a wonder-app-vd03 Site node: 2,885 MB working set 78 min after a cold start, only 150 MB live GC heap, ~2,469 MB on the default process heap across ~6,700 undisposed AssemblyMetadata instances against 473 DLLs on disk. Three sites, all hoisted to static readonly: - SiteRuntime ScriptCompilationService (the dumped one) - InboundAPI InboundScriptExecutor — same defect on the central node; method compiles recur on every re-registration and revision change - CentralUI ScriptAnalysisService — CreateFromFile per sandbox run ScriptAnalysis RoslynScriptCompiler also builds options per call but draws from the static ScriptTrustPolicy.DefaultReferences, so it mints no metadata and is left alone. Guarded by reference-equality on the artifact rather than by watching memory: a bytes-watching test would be flaky, and the leak is native so the managed counters cannot see it at all. The test is proven to fail before the fix. This does NOT close the AddTemplateScript OOM — that path was shown twice not to compile scripts. It explains how a long-running node reaches a native memory state where a large allocation fails with gigabytes free, which is a lead worth re-testing, not a closure.
255 lines
9.8 KiB
C#
255 lines
9.8 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// WP-19: Script Trust Model tests — validates forbidden API detection and compilation.
|
|
///
|
|
/// As of the M3.3 consolidation, <c>ScriptCompilationService.ValidateTrustModel</c>
|
|
/// delegates its forbidden-API verdict to the shared authoritative
|
|
/// <c>ScriptAnalysis.ScriptTrustValidator</c>, which is stricter than SiteRuntime's
|
|
/// original deny-list: ALL of <c>System.Net</c> is forbidden (not just Sockets/Http),
|
|
/// plus reflection gateways, <c>dynamic</c>, <c>Activator</c>,
|
|
/// <c>System.Runtime.InteropServices</c> and <c>Microsoft.Win32</c>. Only
|
|
/// <c>System.Diagnostics.Process</c> is blocked under System.Diagnostics —
|
|
/// <c>Stopwatch</c> stays allowed. The real execution-path compile against
|
|
/// <c>ScriptGlobals</c> / <c>TriggerExpressionGlobals</c> is unchanged.
|
|
/// </summary>
|
|
[Collection("SiteScriptCompileCache")]
|
|
public class ScriptCompilationServiceTests
|
|
{
|
|
private readonly ScriptCompilationService _service;
|
|
|
|
public ScriptCompilationServiceTests()
|
|
{
|
|
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.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<T> (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<T> 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<int> { 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<int>()");
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Native-memory leak guard. <c>ScriptOptions.WithReferences(Assembly[])</c> resolves each
|
|
/// assembly through <c>MetadataReference.CreateFromFile</c>, and every such reference owns an
|
|
/// <c>AssemblyMetadata</c> → <c>PEReader</c> → <c>NativeHeapMemoryBlock</c> — 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
|
|
/// <c>GC.GetTotalAllocatedBytes</c> and to gcdump.
|
|
///
|
|
/// <para>
|
|
/// 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 <c>dumpheap -stat</c> found 6,740 each of
|
|
/// <c>AssemblyMetadata</c> / <c>PEReader</c> / <c>MetadataImageReference</c> against just 473
|
|
/// DLLs on disk — i.e. ~1,348 undisposed copies of this service's 5-assembly reference set.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// 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
|
|
/// <see cref="SiteScriptCompileCache"/> without a second <c>CompileUncached</c>, and the test
|
|
/// would pass without proving anything.
|
|
/// </para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|