Phase 7 Stream A.1 — Core.Scripting project scaffold + ScriptContext + sandbox + AST dependency extractor. First of 3 increments within Stream A. Ships the Roslyn-based script engine's foundation: user C# snippets compile against a constrained ScriptOptions allow-list + get a post-compile sandbox guard, the static tag-dependency set is extracted from the AST at publish time, and the script sees a stable ctx.GetTag/SetVirtualTag/Now/Logger/Deadband API that later streams plug into concrete backends.
ScriptContext abstract base defines the API user scripts see as ctx — GetTag(string) returns DataValueSnapshot so scripts branch on quality naturally, SetVirtualTag(string, object?) is the only write path virtual tags have (OPC UA client writes to virtual nodes rejected separately in DriverNodeManager per ADR-002), Now + Logger + Deadband static helper round out the surface. Concrete subclasses in Streams B + C plug in actual tag backends + per-script Serilog loggers.
ScriptSandbox.Build(contextType) produces the ScriptOptions for every compile — explicit allow-list of six assemblies (System.Private.CoreLib / System.Linq / Core.Abstractions / Core.Scripting / Serilog / the context type's own assembly), with a matching import list so scripts don't need using clauses. Allow-list is plan-level — expanding it is not a casual change.
DependencyExtractor uses CSharpSyntaxWalker to find every ctx.GetTag("literal") and ctx.SetVirtualTag("literal", ...) call, rejects every non-literal path (variable, concatenation, interpolation, method-returned). Rejections carry the exact TextSpan so the Admin UI can point at the offending token. Reads + writes are returned as two separate sets so the virtual-tag engine (Stream B) knows both the subscription targets and the write targets.
Sandbox enforcement turned out needing a second-pass semantic analyzer because .NET 10's type forwarding makes assembly-level restriction leaky — System.Net.Http.HttpClient resolves even with WithReferences limited to six assemblies. ForbiddenTypeAnalyzer runs after Roslyn's Compile() against the SemanticModel, walks every ObjectCreationExpression / InvocationExpression / MemberAccessExpression / IdentifierName, resolves to the containing type's namespace, and rejects any prefix-match against the deny-list (System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Thread, System.Runtime.InteropServices, Microsoft.Win32). Rejections throw ScriptSandboxViolationException with the aggregated list + source spans so the Admin UI surfaces every violation in one round-trip instead of whack-a-mole. System.Environment explicitly stays allowed (read-only process state, doesn't persist or leak outside) and that compromise is pinned by a dedicated test.
ScriptGlobals<TContext> wraps the context as a named field so scripts see ctx instead of the bare globalsType-member-access convention Roslyn defaults to — keeps script ergonomics (ctx.GetTag) consistent with the AST walker's parse shape and the Admin UI's hand-written type stub (coming in Stream F). Generic on TContext so Stream C's alarm-predicate context with an Alarm property inherits cleanly.
ScriptEvaluator<TContext, TResult>.Compile is the three-step gate: (1) Roslyn compile — throws CompilationErrorException on syntax/type errors with Location-carrying diagnostics; (2) ForbiddenTypeAnalyzer semantic pass — catches type-forwarding sandbox escapes; (3) delegate creation. Runtime exceptions from user code propagate unwrapped — the virtual-tag engine in Stream B catches + maps per-tag to BadInternalError quality per Phase 7 decision #11.
29 unit tests covering every surface: DependencyExtractorTests has 14 theories — single/multiple/deduplicated reads, separate write tracking, rejection of variable/concatenated/interpolated/method-returned/empty/whitespace paths, ignoring non-ctx methods named GetTag, empty-source no-op, source span carried in rejections, multiple bad paths reported in one pass, nested literal extraction. ScriptSandboxTests has 15 — happy-path compile + run, SetVirtualTag round-trip, rejection of File.IO + HttpClient + Process.Start + Reflection.Assembly.Load via ScriptSandboxViolationException, Environment.GetEnvironmentVariable explicitly allowed (pinned compromise), script-exception propagation, ctx.Now reachable, Deadband static reachable, LINQ Where/Sum reachable, DataValueSnapshot usable in scripts including quality branches, compile error carries source location.
Next two PRs within Stream A: A.2 adds the compile cache (source-hash keyed) + per-evaluation timeout wrapper; A.3 wires the dedicated scripts-*.log Serilog rolling sink with structured-property filtering + the companion-warning enricher to the main log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the AST walker that extracts static tag dependencies from user scripts
|
||||
/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag
|
||||
/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extracts_single_literal_read()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/Speed").Value;""");
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("Line1/Speed");
|
||||
result.Writes.ShouldBeEmpty();
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracts_multiple_distinct_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var a = ctx.GetTag("Line1/A").Value;
|
||||
var b = ctx.GetTag("Line1/B").Value;
|
||||
return (double)a + (double)b;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
result.Reads.ShouldContain("Line1/A");
|
||||
result.Reads.ShouldContain("Line1/B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deduplicates_identical_reads_across_the_script()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
if (((double)ctx.GetTag("X").Value) > 0)
|
||||
return ctx.GetTag("X").Value;
|
||||
return 0;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(1);
|
||||
result.Reads.ShouldContain("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("InTag").Value;
|
||||
ctx.SetVirtualTag("OutTag", v * 2);
|
||||
return v;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("InTag");
|
||||
result.Writes.ShouldContain("OutTag");
|
||||
result.Reads.ShouldNotContain("OutTag");
|
||||
result.Writes.ShouldNotContain("InTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_variable_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var path = "Line1/Speed";
|
||||
return ctx.GetTag(path).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(1);
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_concatenated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/" + "Speed").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_interpolated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var n = 1;
|
||||
return ctx.GetTag($"Line{n}/Speed").Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_method_returned_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string BuildPath() => "Line1/Speed";
|
||||
return ctx.GetTag(BuildPath()).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_empty_literal_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_whitespace_only_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag(" ").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ignores_non_ctx_method_named_GetTag()
|
||||
{
|
||||
// Scripts are free to define their own helper called "GetTag" — as long as it's
|
||||
// not on the ctx instance, the extractor doesn't pick it up. The sandbox
|
||||
// compile will still reject any path that isn't on the ScriptContext type.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string helper_GetTag(string p) => p;
|
||||
return helper_GetTag("NotATag");
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_source_is_a_no_op()
|
||||
{
|
||||
DependencyExtractor.Extract("").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_carries_source_span_for_UI_pointing()
|
||||
{
|
||||
// Offending path at column 23-29 in the source — Admin UI uses Span to
|
||||
// underline the exact token.
|
||||
const string src = """return ctx.GetTag(path).Value;""";
|
||||
var result = DependencyExtractor.Extract(src);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Span.Start.ShouldBeGreaterThan(0);
|
||||
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var p1 = "A"; var p2 = "B";
|
||||
return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString();
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||
{
|
||||
// Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args
|
||||
// are captured even when the enclosing expression is complex.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value);
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user