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:
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
|
||||
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
|
||||
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
|
||||
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tag-path argument MUST be a literal string expression. Variables,
|
||||
/// concatenation, interpolation, and method-returned strings are rejected because
|
||||
/// the extractor can't statically know what tag they'll resolve to at evaluation
|
||||
/// time — the dependency graph needs to know every possible input up front.
|
||||
/// Rejections carry the exact source span so the Admin UI can point at the offending
|
||||
/// token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Identifier matching is by spelling: the extractor looks for
|
||||
/// <c>ctx.GetTag(...)</c> / <c>ctx.SetVirtualTag(...)</c> literally. A deliberately
|
||||
/// misspelled method call (<c>ctx.GetTagz</c>) is not picked up but will also fail
|
||||
/// to compile against <see cref="ScriptContext"/>, so there's no way to smuggle a
|
||||
/// dependency past the extractor while still having a working script.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DependencyExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
|
||||
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||
/// </summary>
|
||||
public static DependencyExtractionResult Extract(string scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||
return new DependencyExtractionResult(
|
||||
Reads: new HashSet<string>(StringComparer.Ordinal),
|
||||
Writes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Rejections: []);
|
||||
|
||||
var tree = CSharpSyntaxTree.ParseText(scriptSource, options:
|
||||
new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
var root = tree.GetRoot();
|
||||
|
||||
var walker = new Walker();
|
||||
walker.Visit(root);
|
||||
|
||||
return new DependencyExtractionResult(
|
||||
Reads: walker.Reads,
|
||||
Writes: walker.Writes,
|
||||
Rejections: walker.Rejections);
|
||||
}
|
||||
|
||||
private sealed class Walker : CSharpSyntaxWalker
|
||||
{
|
||||
private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
|
||||
private readonly List<DependencyRejection> _rejections = [];
|
||||
|
||||
public IReadOnlySet<string> Reads => _reads;
|
||||
public IReadOnlySet<string> Writes => _writes;
|
||||
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
||||
|
||||
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
// Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...).
|
||||
// Anything else (free functions, chained calls, static calls) is ignored — but
|
||||
// still visit children in case a ctx.GetTag call is nested inside.
|
||||
if (node.Expression is MemberAccessExpressionSyntax member)
|
||||
{
|
||||
var methodName = member.Name.Identifier.ValueText;
|
||||
if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
|
||||
{
|
||||
HandleTagCall(node, methodName);
|
||||
}
|
||||
}
|
||||
base.VisitInvocationExpression(node);
|
||||
}
|
||||
|
||||
private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
|
||||
{
|
||||
var args = node.ArgumentList.Arguments;
|
||||
if (args.Count == 0)
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: node.Span,
|
||||
Message: $"Call to ctx.{methodName} has no arguments. " +
|
||||
"The tag path must be the first argument."));
|
||||
return;
|
||||
}
|
||||
|
||||
var pathArg = args[0].Expression;
|
||||
if (pathArg is not LiteralExpressionSyntax literal
|
||||
|| !literal.Token.IsKind(SyntaxKind.StringLiteralToken))
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: pathArg.Span,
|
||||
Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
|
||||
$"Dynamic paths (variables, concatenation, interpolation, method " +
|
||||
$"calls) are rejected at publish so the dependency graph can be " +
|
||||
$"built statically. Got: {pathArg.Kind()} ({pathArg})"));
|
||||
return;
|
||||
}
|
||||
|
||||
var path = (string?)literal.Token.Value ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: literal.Span,
|
||||
Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (methodName == nameof(ScriptContext.GetTag))
|
||||
_reads.Add(path);
|
||||
else
|
||||
_writes.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
|
||||
public sealed record DependencyExtractionResult(
|
||||
IReadOnlySet<string> Reads,
|
||||
IReadOnlySet<string> Writes,
|
||||
IReadOnlyList<DependencyRejection> Rejections)
|
||||
{
|
||||
/// <summary>True when no rejections were recorded — safe to publish.</summary>
|
||||
public bool IsValid => Rejections.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
|
||||
public sealed record DependencyRejection(TextSpan Span, string Message);
|
||||
Reference in New Issue
Block a user