diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 4217fd8..2d09e39 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -3,6 +3,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs new file mode 100644 index 0000000..34dde7f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs @@ -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; + +/// +/// Parses a script's source text + extracts every ctx.GetTag("literal") and +/// ctx.SetVirtualTag("literal", ...) 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). +/// +/// +/// +/// 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. +/// +/// +/// Identifier matching is by spelling: the extractor looks for +/// ctx.GetTag(...) / ctx.SetVirtualTag(...) literally. A deliberately +/// misspelled method call (ctx.GetTagz) is not picked up but will also fail +/// to compile against , so there's no way to smuggle a +/// dependency past the extractor while still having a working script. +/// +/// +public static class DependencyExtractor +{ + /// + /// Parse + return the inferred read + write tag + /// paths, or a list of rejection messages if non-literal paths were used. + /// + public static DependencyExtractionResult Extract(string scriptSource) + { + if (string.IsNullOrWhiteSpace(scriptSource)) + return new DependencyExtractionResult( + Reads: new HashSet(StringComparer.Ordinal), + Writes: new HashSet(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 _reads = new(StringComparer.Ordinal); + private readonly HashSet _writes = new(StringComparer.Ordinal); + private readonly List _rejections = []; + + public IReadOnlySet Reads => _reads; + public IReadOnlySet Writes => _writes; + public IReadOnlyList 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); + } + } +} + +/// Output of . +public sealed record DependencyExtractionResult( + IReadOnlySet Reads, + IReadOnlySet Writes, + IReadOnlyList Rejections) +{ + /// True when no rejections were recorded — safe to publish. + public bool IsValid => Rejections.Count == 0; +} + +/// A single non-literal-path rejection with the exact source span for UI pointing. +public sealed record DependencyRejection(TextSpan Span, string Message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs new file mode 100644 index 0000000..be7b1fb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs @@ -0,0 +1,152 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Post-compile sandbox guard. ScriptOptions alone can't reliably +/// constrain the type surface a script can reach because .NET 10's type-forwarding +/// system resolves many BCL types through multiple assemblies — restricting the +/// reference list doesn't stop System.Net.Http.HttpClient from being found if +/// any transitive reference forwards to System.Net.Http. This analyzer walks +/// the script's syntax tree after compile, uses the to +/// resolve every type / member reference, and rejects any whose containing namespace +/// matches a deny-list pattern. +/// +/// +/// +/// Deny-list is the authoritative Phase 7 plan decision #6 set: +/// System.IO, System.Net, System.Diagnostics.Process, +/// System.Reflection, System.Threading.Thread, +/// System.Runtime.InteropServices. System.Environment (for process +/// env-var read) is explicitly left allowed — it's read-only process state, doesn't +/// persist outside, and the test file pins this compromise so tightening later is +/// a deliberate plan decision. +/// +/// +/// Deny-list prefix match. System.Net catches System.Net.Http, +/// System.Net.Sockets, System.Net.NetworkInformation, etc. — every +/// subnamespace. If a script needs something under a denied prefix, Phase 7's +/// operator audience authors it through a helper the plan team adds as part of +/// the surface, not by unlocking the namespace. +/// +/// +public static class ForbiddenTypeAnalyzer +{ + /// + /// Namespace prefixes scripts are NOT allowed to reference. Each string is + /// matched as a prefix against the resolved symbol's namespace name (dot- + /// delimited), so System.IO catches System.IO.File, + /// System.IO.Pipes, and any future subnamespace without needing explicit + /// enumeration. + /// + public static readonly IReadOnlyList ForbiddenNamespacePrefixes = + [ + "System.IO", + "System.Net", + "System.Diagnostics", // catches Process, ProcessStartInfo, EventLog, Trace/Debug file sinks + "System.Reflection", + "System.Threading.Thread", // raw Thread — Tasks stay allowed (different namespace) + "System.Runtime.InteropServices", + "Microsoft.Win32", // registry + ]; + + /// + /// Scan the for references to forbidden types. + /// Returns empty list when the script is clean; non-empty list means the script + /// must be rejected at publish with the rejections surfaced to the operator. + /// + public static IReadOnlyList Analyze(Compilation compilation) + { + if (compilation is null) throw new ArgumentNullException(nameof(compilation)); + + var rejections = new List(); + foreach (var tree in compilation.SyntaxTrees) + { + var semantic = compilation.GetSemanticModel(tree); + var root = tree.GetRoot(); + foreach (var node in root.DescendantNodes()) + { + switch (node) + { + case ObjectCreationExpressionSyntax obj: + CheckSymbol(semantic.GetSymbolInfo(obj.Type).Symbol, obj.Type.Span, rejections); + break; + case InvocationExpressionSyntax inv when inv.Expression is MemberAccessExpressionSyntax memberAcc: + CheckSymbol(semantic.GetSymbolInfo(memberAcc.Expression).Symbol, memberAcc.Expression.Span, rejections); + CheckSymbol(semantic.GetSymbolInfo(inv).Symbol, inv.Span, rejections); + break; + case MemberAccessExpressionSyntax mem: + // Catches static calls like System.IO.File.ReadAllText(...) — the + // MemberAccess "System.IO.File" resolves to the File type symbol + // whose containing namespace is System.IO, triggering a rejection. + CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections); + break; + case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax: + CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections); + break; + } + } + } + return rejections; + } + + private static void CheckSymbol(ISymbol? symbol, TextSpan span, List rejections) + { + if (symbol is null) return; + + var typeSymbol = symbol switch + { + ITypeSymbol t => t, + IMethodSymbol m => m.ContainingType, + IPropertySymbol p => p.ContainingType, + IFieldSymbol f => f.ContainingType, + _ => null, + }; + if (typeSymbol is null) return; + + var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty; + foreach (var forbidden in ForbiddenNamespacePrefixes) + { + if (ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal)) + { + rejections.Add(new ForbiddenTypeRejection( + Span: span, + TypeName: typeSymbol.ToDisplayString(), + Namespace: ns, + Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " + + $"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules.")); + return; + } + } + } +} + +/// A single forbidden-type reference in a user script. +public sealed record ForbiddenTypeRejection( + TextSpan Span, + string TypeName, + string Namespace, + string Message); + +/// Thrown from when the +/// post-compile forbidden-type analyzer finds references to denied namespaces. +public sealed class ScriptSandboxViolationException : Exception +{ + public IReadOnlyList Rejections { get; } + + public ScriptSandboxViolationException(IReadOnlyList rejections) + : base(BuildMessage(rejections)) + { + Rejections = rejections; + } + + private static string BuildMessage(IReadOnlyList rejections) + { + var lines = rejections.Select(r => $" - {r.Message}"); + return "Script references types outside the Phase 7 sandbox allow-list:\n" + + string.Join("\n", lines); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs new file mode 100644 index 0000000..9cad820 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs @@ -0,0 +1,80 @@ +using Serilog; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// The API user scripts see as the global ctx. Abstract — concrete subclasses +/// (e.g. VirtualTagScriptContext, AlarmScriptContext) plug in the +/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan +/// decision #6: scripts can read any tag, write only to virtual tags, and have no +/// other .NET reach — no HttpClient, no File, no Process, no reflection. +/// +/// +/// +/// Every member on this type MUST be serializable in the narrow sense that +/// can recognize tag-access call sites from the +/// script AST. Method names used from scripts are locked — renaming +/// or is a breaking change for every +/// authored script and the dependency extractor must update in lockstep. +/// +/// +/// New helpers (, ) are additive: adding a +/// method doesn't invalidate existing scripts. Do not remove or rename without a +/// plan-level decision + migration for authored scripts. +/// +/// +public abstract class ScriptContext +{ + /// + /// Read a tag's current value + quality + source timestamp. Path syntax is + /// Enterprise/Site/Area/Line/Equipment/TagName (forward-slash delimited, + /// matching the Equipment-namespace browse tree). Returns a + /// so scripts branch on quality without a second + /// call. + /// + /// + /// MUST be a string literal in the script source — dynamic + /// paths (variables, concatenation, method-returned strings) are rejected at + /// publish by . This is intentional: the static + /// dependency set is required for the change-driven scheduler to subscribe to the + /// right upstream tags at load time. + /// + public abstract DataValueSnapshot GetTag(string path); + + /// + /// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced + /// tags — the OPC UA dispatch in DriverNodeManager rejects that separately + /// per ADR-002 with BadUserAccessDenied. This method is the only write path + /// virtual tags have. + /// + /// + /// Path rules identical to — literal only, dependency + /// extractor tracks the write targets so the engine knows what downstream + /// subscribers to notify. + /// + public abstract void SetVirtualTag(string path, object? value); + + /// + /// Current UTC timestamp. Prefer this over in + /// scripts so the harness can supply a deterministic clock for tests. + /// + public abstract DateTime Now { get; } + + /// + /// Per-script Serilog logger. Output lands in the dedicated scripts-*.log + /// sink with structured property ScriptName = the script's configured name. + /// Use at error level to surface problems; main opcua-*.log receives a + /// companion WARN entry so operators see script errors in the primary log. + /// + public abstract ILogger Logger { get; } + + /// + /// Deadband helper — returns true when differs + /// from by more than . + /// Useful for alarm predicates that shouldn't flicker on small noise. Pure + /// function; no side effects. + /// + public static bool Deadband(double current, double previous, double tolerance) + => Math.Abs(current - previous) > tolerance; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs new file mode 100644 index 0000000..4f406ea --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs @@ -0,0 +1,75 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Compiles + runs user scripts against a subclass. Core +/// evaluator — no caching, no timeout, no logging side-effects yet (those land in +/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency +/// scheduler + alarm state machine. +/// +/// +/// +/// Scripts are compiled against so the +/// context member is named ctx in the script, matching the +/// 's walker and the Admin UI type stub. +/// +/// +/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax +/// errors + type-resolution failures, throws ; +/// (2) runs against the semantic model — +/// catches sandbox escapes that slipped past reference restrictions due to .NET's +/// type forwarding, throws ; (3) +/// delegate creation — throws at this layer only for internal Roslyn bugs, not +/// user error. +/// +/// +/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag +/// engine (Stream B) catches them per-tag + maps to BadInternalError +/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so +/// tests can assert on the original exception type. +/// +/// +public sealed class ScriptEvaluator + where TContext : ScriptContext +{ + private readonly ScriptRunner _runner; + + private ScriptEvaluator(ScriptRunner runner) + { + _runner = runner; + } + + public static ScriptEvaluator Compile(string scriptSource) + { + if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource)); + + var options = ScriptSandbox.Build(typeof(TContext)); + var script = CSharpScript.Create( + code: scriptSource, + options: options, + globalsType: typeof(ScriptGlobals)); + + // Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors. + var diagnostics = script.Compile(); + + // Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list + // leaks due to type forwarding. + var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation()); + if (rejections.Count > 0) + throw new ScriptSandboxViolationException(rejections); + + // Step 3 — materialize the callable delegate. + var runner = script.CreateDelegate(); + return new ScriptEvaluator(runner); + } + + /// Run against an already-constructed context. + public Task RunAsync(TContext context, CancellationToken ct = default) + { + if (context is null) throw new ArgumentNullException(nameof(context)); + var globals = new ScriptGlobals { ctx = context }; + return _runner(globals, ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs new file mode 100644 index 0000000..71ffb58 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Wraps a as a named field so user scripts see +/// ctx.GetTag(...) instead of the bare GetTag(...) that Roslyn's +/// globalsType convention would produce. Keeps the script ergonomics operators +/// author against consistent with the dependency extractor (which looks for the +/// ctx. prefix) and with the Admin UI hand-written type stub. +/// +/// +/// Generic on so alarm predicates can use a richer +/// context (e.g. with an Alarm property carrying the owning condition's +/// metadata) without affecting virtual-tag contexts. +/// +public class ScriptGlobals + where TContext : ScriptContext +{ + public TContext ctx { get; set; } = default!; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs new file mode 100644 index 0000000..d4a207e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs @@ -0,0 +1,87 @@ +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; + +/// +/// Factory for the every user script is compiled against. +/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the +/// assemblies + namespaces the script API needs; no System.IO, no +/// System.Net, no System.Diagnostics.Process, no +/// System.Reflection. Attempts to reference those types in a script fail at +/// compile with a compiler error that points at the exact span — the operator sees +/// the rejection before publish, not at evaluation. +/// +/// +/// +/// Roslyn's default references mscorlib / +/// System.Runtime transitively which pulls in every type in the BCL — this +/// class overrides that with an explicit minimal allow-list. +/// +/// +/// Namespaces pre-imported so scripts don't have to write using clauses: +/// System, System.Math-style statics are reachable via +/// , and ZB.MOM.WW.OtOpcUa.Core.Abstractions so scripts +/// can name directly. +/// +/// +/// The sandbox cannot prevent a script from allocating unbounded memory or +/// spinning in a tight loop — those are budget concerns, handled by the +/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets +/// operators preview output before publishing. +/// +/// +public static class ScriptSandbox +{ + /// + /// Build the used for every virtual-tag / alarm + /// script. is the concrete + /// subclass the globals will be of — the compiler + /// uses its type to resolve ctx.GetTag(...) calls. + /// + public static ScriptOptions Build(Type contextType) + { + if (contextType is null) throw new ArgumentNullException(nameof(contextType)); + if (!typeof(ScriptContext).IsAssignableFrom(contextType)) + throw new ArgumentException( + $"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType)); + + // Allow-listed assemblies — each explicitly chosen. Adding here is a + // plan-level decision; do not expand casually. HashSet so adding the + // contextType's assembly is idempotent when it happens to be Core.Scripting + // already. + var allowedAssemblies = new HashSet + { + // System.Private.CoreLib — primitives (int, double, bool, string, DateTime, + // TimeSpan, Math, Convert, nullable). Can't practically script without it. + typeof(object).Assembly, + // System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.). + typeof(System.Linq.Enumerable).Assembly, + // Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name + // the types they receive from ctx.GetTag. + typeof(DataValueSnapshot).Assembly, + // Core.Scripting itself — ScriptContext base class + Deadband static. + typeof(ScriptContext).Assembly, + // Serilog.ILogger — script-side logger type. + typeof(Serilog.ILogger).Assembly, + // Concrete context type's assembly — production contexts subclass + // ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their + // own subclass. The globals wrapper is generic on this type so Roslyn must + // be able to resolve it during compilation. + contextType.Assembly, + }; + + var allowedImports = new[] + { + "System", + "System.Linq", + "ZB.MOM.WW.OtOpcUa.Core.Abstractions", + "ZB.MOM.WW.OtOpcUa.Core.Scripting", + }; + + return ScriptOptions.Default + .WithReferences(allowedAssemblies) + .WithImports(allowedImports); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj new file mode 100644 index 0000000..5e3f4f9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Core.Scripting + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs new file mode 100644 index 0000000..4387dc6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/DependencyExtractorTests.cs @@ -0,0 +1,194 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// 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). +/// +[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); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs new file mode 100644 index 0000000..94e7f97 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/FakeScriptContext.cs @@ -0,0 +1,40 @@ +using Serilog; +using Serilog.Core; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// In-memory for tests. Holds a tag dictionary + a write +/// log + a deterministic clock. Concrete subclasses in production will wire +/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they +/// hit a plain dictionary. +/// +public sealed class FakeScriptContext : ScriptContext +{ + public Dictionary Tags { get; } = new(StringComparer.Ordinal); + public List<(string Path, object? Value)> Writes { get; } = []; + + public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger(); + + public override DataValueSnapshot GetTag(string path) + { + return Tags.TryGetValue(path, out var v) + ? v + : new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown + } + + public override void SetVirtualTag(string path, object? value) + { + Writes.Add((path, value)); + } + + public FakeScriptContext Seed(string path, object? value, + uint statusCode = 0u, DateTime? sourceTs = null) + { + Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now); + return this; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs new file mode 100644 index 0000000..3ac74f5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs @@ -0,0 +1,182 @@ +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_GetEnvironmentVariable_at_compile() + { + // Environment lives in System.Private.CoreLib (allow-listed for primitives) — + // BUT calling .GetEnvironmentVariable exposes process state we don't want in + // scripts. In an allow-list sandbox this passes because mscorlib is allowed; + // relying on ScriptSandbox alone isn't enough for the Environment class. We + // document here that the CURRENT sandbox allows Environment — acceptable because + // Environment doesn't leak outside the process boundary, doesn't side-effect + // persistent state, and Phase 7 plan decision #6 targets File/Net/Process/ + // reflection specifically. + // + // This test LOCKS that compromise: operators should not be surprised if a + // script reads an env var. If we later decide to tighten, this test flips. + var evaluator = ScriptEvaluator.Compile( + """return System.Environment.GetEnvironmentVariable("PATH");"""); + evaluator.ShouldNotBeNull(); + } + + [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(); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj new file mode 100644 index 0000000..6fd1f4c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +