Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty
36774842cf Phase 7 Stream A.3 — ScriptLoggerFactory + ScriptLogCompanionSink. Third of 3 increments closing out Stream A. Adds the Serilog plumbing that ties script-emitted log events to the dedicated scripts-*.log rolling sink with structured-property filtering AND forwards script Error+ events to the main opcua-*.log at Warning level so operators see script failures in the primary log without drowning it in Debug/Info script chatter. Both pieces are library-level building blocks — the actual file-sink + logger composition at server startup happens in Stream F (Admin UI) / Stream G (address-space wiring). This PR ships the reusable factory + sink + tests so any consumer can wire them up without rediscovering the structured-property contract.
ScriptLoggerFactory wraps a Serilog root logger (the scripts-*.log pipeline) and .Create(scriptName) returns a per-script ILogger with the ScriptName structured property pre-bound via ForContext. The structured property name is a public const (ScriptNameProperty = "ScriptName") because the Admin UI's log-viewer filter references this exact string — changing it breaks the filter silently, so it's stable by contract. Factory constructor rejects a null root logger; Create rejects null/empty/whitespace script names. No per-evaluation allocation in the hot path — engines (Stream B virtual-tag / Stream C scripted-alarm) create one factory per engine instance then cache per-script loggers beside the ScriptContext instances they already build.

ScriptLogCompanionSink is a Serilog ILogEventSink that forwards Error+ events from the script-logger pipeline to a separate "main" logger (the opcua-*.log pipeline in production) at Warning level. Rationale: operators usually watch the main server log, not scripts-*.log. Script authors log Info/Debug liberally during development — those stay in the scripts file. When a script actually fails (Error or Fatal), the operator needs to see it in the primary log so it can't be missed. Downgrading to Warning in the main log marks these as "needs attention but not a core server issue" since the server itself is healthy; the script author fixes the script. Forwarded event includes the ScriptName property (so operators can tell which script failed at a glance), the OriginalLevel (Error vs Fatal, preserved), the rendered message, and the original exception (preserved so the main log keeps the full stack trace — critical for diagnosis). Missing ScriptName property falls back to "unknown" without throwing; bypassing the factory is defensive but shouldn't happen in practice. Mirror threshold is configurable via constructor (defaults to LogEventLevel.Error) so deployments with stricter signal/noise requirements can raise it to Fatal.

15 new unit tests across two files. ScriptLoggerFactoryTests (6): Create sets the ScriptName structured property, each script gets its own property value across fan-out, Error-level event preserves level and exception, null root rejected, empty/whitespace/null name rejected, ScriptNameProperty const is stable at "ScriptName" (external-contract guard). ScriptLogCompanionSinkTests (9): Info/Warning events land in scripts sink only (not mirrored), Error event mirrored to main at Warning level (level-downgrade behavior), mirrored event includes ScriptName + OriginalLevel properties, mirrored event preserves exception for main-log stack-trace diagnosis, Fatal mirrored identically to Error, missing ScriptName falls back to "unknown" without throwing (defensive), null main logger rejected, custom mirror threshold (raised to Fatal) applied correctly.

Full Core.Scripting test suite after Stream A: 63/63 green (29 A.1 + 19 A.2 + 15 A.3). Stream A is complete — the scripting engine foundation, sandbox, sandbox-defense-in-depth, AST-inferred dependency extraction, compile cache, per-evaluation timeout, per-script logger with structured-property filtering, and companion-warn forwarding are all shipped and tested. Streams B through G build on this; Stream H closes out the phase with the compliance script + test baseline + merge to v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:42:48 -04:00
cb5d7b2d58 Merge pull request 'Phase 7 Stream A.2 — compile cache + per-evaluation timeout wrapper' (#178) from phase-7-stream-a2-cache-timeout into v2 2026-04-20 16:41:07 -04:00
Joseph Doherty
0ae715cca4 Phase 7 Stream A.2 — compile cache + per-evaluation timeout wrapper. Second of 3 increments within Stream A. Adds two independent resilience primitives that the virtual-tag engine (Stream B) and scripted-alarm engine (Stream C) will compose with the base ScriptEvaluator. Both are generic on (TContext, TResult) so different engines get their own instances without cross-contamination.
CompiledScriptCache<TContext, TResult> — source-hash-keyed cache of compiled evaluators. Roslyn compilation is the most expensive step in the evaluator pipeline (5-20ms per script depending on size); re-compiling on every value-change event would starve the engine. ConcurrentDictionary of Lazy<ScriptEvaluator> with ExecutionAndPublication mode ensures concurrent callers never double-compile even on a cold cache race. Failed compiles evict the cache entry so an Admin UI retry with corrected source actually recompiles (otherwise the cached exception would persist). Whitespace-sensitive hash — reformatting a script misses the cache on purpose, simpler than AST-canonicalize and happens rarely. No capacity bound because virtual-tag + alarm scripts are config-DB bounded (thousands, not millions); if scale pushes past that in v3 an LRU eviction slots in behind the same API.

TimedScriptEvaluator<TContext, TResult> — wraps a ScriptEvaluator with a per-evaluation wall-clock timeout (default 250ms per Phase 7 plan Stream A.4, configurable per tag so slower backends can widen). Critical implementation detail: the underlying Roslyn ScriptRunner executes synchronously on the calling thread for CPU-bound user scripts, returning an already-completed Task before the caller can register a timeout. Naive `Task.WaitAsync(timeout)` would see the completed task and never fire. Fix: push evaluation to a thread-pool thread via Task.Run, so the caller's thread is free to wait and the timeout reliably fires after the configured budget. Known trade-off (documented in the class summary): when a script times out, the underlying evaluation task continues running on the thread-pool thread until Roslyn returns; in the CPU-bound-infinite-loop case it's effectively leaked until the runtime decides to unwind. Tighter CPU budgeting would require an out-of-process script runner (v3 concern). In practice the timeout + structured warning log surfaces the offending script so the operator fixes it, and the orphan thread is rare. Caller-supplied CancellationToken is honored and takes precedence over the timeout, so driver-shutdown paths see a clean OperationCanceledException rather than a misclassified ScriptTimeoutException.

ScriptTimeoutException carries the configured Timeout and a diagnostic message pointing the operator at ctx.Logger output around the failure plus suggesting widening the timeout, simplifying the script, or moving heavy work out of the evaluation path. The virtual-tag engine (Stream B) will catch this and map the owning tag's quality to BadInternalError per Phase 7 decision #11, logging a structured warning with the offending script name.

Tests: CompiledScriptCacheTests (10) — first-call compile, identical-source dedupe to same instance, different-source produces different evaluator, whitespace-sensitivity documented, cached evaluator still runs correctly, failed compile evicted for retry, Clear drops entries, concurrent GetOrCompile of the same source deduplicates to one instance, different TContext/TResult use separate cache instances, null source rejected. TimedScriptEvaluatorTests (9) — fast script completes under timeout, CPU-bound script throws ScriptTimeoutException, caller cancellation takes precedence over timeout (shutdown path correctness), default 250ms per plan, zero/negative timeout rejected at construction, null inner rejected, null context rejected, user-thrown exceptions propagate unwrapped (not conflated with timeout), timeout exception message contains diagnostic guidance. Full suite: 48/48 green (29 from A.1 + 19 new).

Next: Stream A.3 wires the dedicated scripts-*.log Serilog rolling sink + structured-property filtering + companion-WARN enricher to the main log, closing out Stream A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:38:43 -04:00
d2bfcd9f1e Merge pull request 'Phase 7 Stream A.1 — Core.Scripting project scaffold + ScriptContext + sandbox + AST dependency extractor' (#177) from phase-7-stream-a1-core-scripting into v2 2026-04-20 16:29:44 -04:00
Joseph Doherty
e4dae01bac 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>
2026-04-20 16:27:07 -04:00
6ae638a6de Merge pull request 'ADR-002 — driver-vs-virtual dispatch for Phase 7 scripting' (#176) from adr-002-driver-vs-virtual-dispatch into v2 2026-04-20 16:10:30 -04:00
20 changed files with 1866 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
@@ -26,6 +27,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>

View File

@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
/// re-compiling on every value-change event would starve the virtual-tag engine.
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
/// their own cache instance — there's no cross-type pollution.
/// </summary>
/// <remarks>
/// <para>
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
/// threads block on the in-flight compile rather than racing to duplicate work.
/// </para>
/// <para>
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
/// practice. Whitespace changes therefore miss the cache on purpose; operators
/// see re-compile time on their first evaluation after a format-only edit which
/// is rare and benign.
/// </para>
/// <para>
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
/// bounded by config DB (typically low thousands). If that changes in v3, add an
/// LRU eviction policy — the API stays the same.
/// </para>
/// </remarks>
public sealed class CompiledScriptCache<TContext, TResult>
where TContext : ScriptContext
{
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
/// <summary>
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
/// on first sight + reusing thereafter. If the source fails to compile, the
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
/// the next call retries (useful during Admin UI authoring when the operator is
/// still fixing syntax).
/// </summary>
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
var key = HashSource(scriptSource);
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return lazy.Value;
}
catch
{
// Failed compile — evict so a retry with corrected source can succeed.
_cache.TryRemove(key, out _);
throw;
}
}
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
public int Count => _cache.Count;
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
public void Clear() => _cache.Clear();
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
public bool Contains(string scriptSource)
=> _cache.ContainsKey(HashSource(scriptSource));
private static string HashSource(string source)
{
var bytes = Encoding.UTF8.GetBytes(source);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
}

View 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);

View File

@@ -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;
/// <summary>
/// Post-compile sandbox guard. <c>ScriptOptions</c> 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 <c>System.Net.Http.HttpClient</c> from being found if
/// any transitive reference forwards to <c>System.Net.Http</c>. This analyzer walks
/// the script's syntax tree after compile, uses the <see cref="SemanticModel"/> to
/// resolve every type / member reference, and rejects any whose containing namespace
/// matches a deny-list pattern.
/// </summary>
/// <remarks>
/// <para>
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics.Process</c>,
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
/// <c>System.Runtime.InteropServices</c>. <c>System.Environment</c> (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.
/// </para>
/// <para>
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
/// <c>System.Net.Sockets</c>, <c>System.Net.NetworkInformation</c>, 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 <see cref="ScriptContext"/> surface, not by unlocking the namespace.
/// </para>
/// </remarks>
public static class ForbiddenTypeAnalyzer
{
/// <summary>
/// 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 <c>System.IO</c> catches <c>System.IO.File</c>,
/// <c>System.IO.Pipes</c>, and any future subnamespace without needing explicit
/// enumeration.
/// </summary>
public static readonly IReadOnlyList<string> 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
];
/// <summary>
/// Scan the <paramref name="compilation"/> 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.
/// </summary>
public static IReadOnlyList<ForbiddenTypeRejection> Analyze(Compilation compilation)
{
if (compilation is null) throw new ArgumentNullException(nameof(compilation));
var rejections = new List<ForbiddenTypeRejection>();
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<ForbiddenTypeRejection> 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;
}
}
}
}
/// <summary>A single forbidden-type reference in a user script.</summary>
public sealed record ForbiddenTypeRejection(
TextSpan Span,
string TypeName,
string Namespace,
string Message);
/// <summary>Thrown from <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> when the
/// post-compile forbidden-type analyzer finds references to denied namespaces.</summary>
public sealed class ScriptSandboxViolationException : Exception
{
public IReadOnlyList<ForbiddenTypeRejection> Rejections { get; }
public ScriptSandboxViolationException(IReadOnlyList<ForbiddenTypeRejection> rejections)
: base(BuildMessage(rejections))
{
Rejections = rejections;
}
private static string BuildMessage(IReadOnlyList<ForbiddenTypeRejection> rejections)
{
var lines = rejections.Select(r => $" - {r.Message}");
return "Script references types outside the Phase 7 sandbox allow-list:\n"
+ string.Join("\n", lines);
}
}

View File

@@ -0,0 +1,80 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) 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.
/// </summary>
/// <remarks>
/// <para>
/// Every member on this type MUST be serializable in the narrow sense that
/// <see cref="DependencyExtractor"/> can recognize tag-access call sites from the
/// script AST. Method names used from scripts are locked — renaming
/// <see cref="GetTag"/> or <see cref="SetVirtualTag"/> is a breaking change for every
/// authored script and the dependency extractor must update in lockstep.
/// </para>
/// <para>
/// New helpers (<see cref="Now"/>, <see cref="Deadband"/>) are additive: adding a
/// method doesn't invalidate existing scripts. Do not remove or rename without a
/// plan-level decision + migration for authored scripts.
/// </para>
/// </remarks>
public abstract class ScriptContext
{
/// <summary>
/// Read a tag's current value + quality + source timestamp. Path syntax is
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c> (forward-slash delimited,
/// matching the Equipment-namespace browse tree). Returns a
/// <see cref="DataValueSnapshot"/> so scripts branch on quality without a second
/// call.
/// </summary>
/// <remarks>
/// <paramref name="path"/> MUST be a string literal in the script source — dynamic
/// paths (variables, concatenation, method-returned strings) are rejected at
/// publish by <see cref="DependencyExtractor"/>. This is intentional: the static
/// dependency set is required for the change-driven scheduler to subscribe to the
/// right upstream tags at load time.
/// </remarks>
public abstract DataValueSnapshot GetTag(string path);
/// <summary>
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
/// virtual tags have.
/// </summary>
/// <remarks>
/// Path rules identical to <see cref="GetTag"/> — literal only, dependency
/// extractor tracks the write targets so the engine knows what downstream
/// subscribers to notify.
/// </remarks>
public abstract void SetVirtualTag(string path, object? value);
/// <summary>
/// Current UTC timestamp. Prefer this over <see cref="DateTime.UtcNow"/> in
/// scripts so the harness can supply a deterministic clock for tests.
/// </summary>
public abstract DateTime Now { get; }
/// <summary>
/// Per-script Serilog logger. Output lands in the dedicated <c>scripts-*.log</c>
/// sink with structured property <c>ScriptName</c> = the script's configured name.
/// Use at error level to surface problems; main <c>opcua-*.log</c> receives a
/// companion WARN entry so operators see script errors in the primary log.
/// </summary>
public abstract ILogger Logger { get; }
/// <summary>
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
/// function; no side effects.
/// </summary>
public static bool Deadband(double current, double previous, double tolerance)
=> Math.Abs(current - previous) > tolerance;
}

View File

@@ -0,0 +1,75 @@
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> 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.
/// </summary>
/// <remarks>
/// <para>
/// Scripts are compiled against <see cref="ScriptGlobals{TContext}"/> so the
/// context member is named <c>ctx</c> in the script, matching the
/// <see cref="DependencyExtractor"/>'s walker and the Admin UI type stub.
/// </para>
/// <para>
/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax
/// errors + type-resolution failures, throws <see cref="CompilationErrorException"/>;
/// (2) <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model —
/// catches sandbox escapes that slipped past reference restrictions due to .NET's
/// type forwarding, throws <see cref="ScriptSandboxViolationException"/>; (3)
/// delegate creation — throws at this layer only for internal Roslyn bugs, not
/// user error.
/// </para>
/// <para>
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
/// engine (Stream B) catches them per-tag + maps to <c>BadInternalError</c>
/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so
/// tests can assert on the original exception type.
/// </para>
/// </remarks>
public sealed class ScriptEvaluator<TContext, TResult>
where TContext : ScriptContext
{
private readonly ScriptRunner<TResult> _runner;
private ScriptEvaluator(ScriptRunner<TResult> runner)
{
_runner = runner;
}
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
var options = ScriptSandbox.Build(typeof(TContext));
var script = CSharpScript.Create<TResult>(
code: scriptSource,
options: options,
globalsType: typeof(ScriptGlobals<TContext>));
// 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<TContext, TResult>(runner);
}
/// <summary>Run against an already-constructed context.</summary>
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
var globals = new ScriptGlobals<TContext> { ctx = context };
return _runner(globals, ct);
}
}

View File

@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Wraps a <see cref="ScriptContext"/> as a named field so user scripts see
/// <c>ctx.GetTag(...)</c> instead of the bare <c>GetTag(...)</c> that Roslyn's
/// globalsType convention would produce. Keeps the script ergonomics operators
/// author against consistent with the dependency extractor (which looks for the
/// <c>ctx.</c> prefix) and with the Admin UI hand-written type stub.
/// </summary>
/// <remarks>
/// Generic on <typeparamref name="TContext"/> so alarm predicates can use a richer
/// context (e.g. with an <c>Alarm</c> property carrying the owning condition's
/// metadata) without affecting virtual-tag contexts.
/// </remarks>
public class ScriptGlobals<TContext>
where TContext : ScriptContext
{
public TContext ctx { get; set; } = default!;
}

View File

@@ -0,0 +1,65 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Serilog sink that mirrors script log events at <see cref="LogEventLevel.Error"/>
/// or higher to a companion logger (typically the main <c>opcua-*.log</c>) at
/// <see cref="LogEventLevel.Warning"/>. Lets operators see script errors in the
/// primary server log without drowning it in Debug/Info/Warning noise from scripts.
/// </summary>
/// <remarks>
/// <para>
/// Registered alongside the dedicated <c>scripts-*.log</c> rolling file sink in
/// the root script-logger configuration — events below Error land only in the
/// scripts file; Error/Fatal events land in both the scripts file (at original
/// level) and the main log (downgraded to Warning since the main log's audience
/// is server operators, not script authors).
/// </para>
/// <para>
/// The forwarded message preserves the <c>ScriptName</c> property so operators
/// reading the main log can tell which script raised the error at a glance.
/// Original exception (if any) is attached so the main log's diagnostics keep
/// the full stack trace.
/// </para>
/// </remarks>
public sealed class ScriptLogCompanionSink : ILogEventSink
{
private readonly ILogger _mainLogger;
private readonly LogEventLevel _minMirrorLevel;
public ScriptLogCompanionSink(ILogger mainLogger, LogEventLevel minMirrorLevel = LogEventLevel.Error)
{
_mainLogger = mainLogger ?? throw new ArgumentNullException(nameof(mainLogger));
_minMirrorLevel = minMirrorLevel;
}
public void Emit(LogEvent logEvent)
{
if (logEvent is null) return;
if (logEvent.Level < _minMirrorLevel) return;
var scriptName = "unknown";
if (logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
&& prop is ScalarValue sv && sv.Value is string s)
{
scriptName = s;
}
var rendered = logEvent.RenderMessage();
if (logEvent.Exception is not null)
{
_mainLogger.Warning(logEvent.Exception,
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
scriptName, logEvent.Level, rendered);
}
else
{
_mainLogger.Warning(
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
scriptName, logEvent.Level, rendered);
}
}
}

View File

@@ -0,0 +1,48 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Creates per-script Serilog <see cref="ILogger"/> instances with the
/// <c>ScriptName</c> structured property pre-bound. Every log call from a user
/// script carries the owning virtual-tag or alarm name so operators can filter the
/// dedicated <c>scripts-*.log</c> sink by script in the Admin UI.
/// </summary>
/// <remarks>
/// <para>
/// Factory-based — the engine (Stream B / C) constructs exactly one instance
/// from the root script-logger pipeline at startup, then derives a per-script
/// logger for each <see cref="ScriptContext"/> it builds. No per-evaluation
/// allocation in the hot path.
/// </para>
/// <para>
/// The wrapped root logger is responsible for output wiring — typically a
/// rolling file sink to <c>scripts-*.log</c> plus a
/// <see cref="ScriptLogCompanionSink"/> that forwards Error-or-higher events
/// to the main server log at Warning level so operators see script errors
/// in the primary log without drowning it in Info noise.
/// </para>
/// </remarks>
public sealed class ScriptLoggerFactory
{
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
public const string ScriptNameProperty = "ScriptName";
private readonly ILogger _rootLogger;
public ScriptLoggerFactory(ILogger rootLogger)
{
_rootLogger = rootLogger ?? throw new ArgumentNullException(nameof(rootLogger));
}
/// <summary>
/// Create a per-script logger. Every event it emits carries
/// <c>ScriptName=<paramref name="scriptName"/></c> as a structured property.
/// </summary>
public ILogger Create(string scriptName)
{
if (string.IsNullOrWhiteSpace(scriptName))
throw new ArgumentException("Script name is required.", nameof(scriptName));
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
}
}

View File

@@ -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;
/// <summary>
/// Factory for the <see cref="ScriptOptions"/> 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 <c>System.IO</c>, no
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
/// <c>System.Reflection</c>. 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.
/// </summary>
/// <remarks>
/// <para>
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
/// class overrides that with an explicit minimal allow-list.
/// </para>
/// <para>
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
/// can name <see cref="DataValueSnapshot"/> directly.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class ScriptSandbox
{
/// <summary>
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
/// script. <paramref name="contextType"/> is the concrete
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
/// </summary>
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.Reflection.Assembly>
{
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
// TimeSpan, Math, Convert, nullable<T>). 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);
}
}

View File

@@ -0,0 +1,102 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
/// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
/// per tag so deployments with slower backends can widen it.
/// </summary>
/// <remarks>
/// <para>
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
/// rather than a cancellation-token-only approach because Roslyn-compiled
/// scripts don't internally poll the cancellation token unless the user code
/// does async work. A CPU-bound infinite loop in a script won't honor a
/// cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
/// regardless of whether the inner task completes.
/// </para>
/// <para>
/// <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
/// task continues running on a thread-pool thread until the Roslyn runtime
/// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
/// the thread is tied up until the runtime decides to return, which it may
/// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
/// CPU budgeting would require an out-of-process script runner, which is a v3
/// concern. In practice, the timeout + structured warning log surfaces the
/// offending script so the operator can fix it; the orphan thread is rare.
/// </para>
/// <para>
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
/// cancels before the timeout fires, the caller's cancel wins and the
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
/// see those as timeouts.
/// </para>
/// </remarks>
public sealed class TimedScriptEvaluator<TContext, TResult>
where TContext : ScriptContext
{
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
private readonly ScriptEvaluator<TContext, TResult> _inner;
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
public TimeSpan Timeout { get; }
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
: this(inner, DefaultTimeout)
{
}
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
if (timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
Timeout = timeout;
}
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
// Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
// loop with no async work) doesn't hog the caller's thread before WaitAsync
// gets to register its timeout. Without this, Roslyn's ScriptRunner executes
// synchronously on the calling thread and returns an already-completed Task,
// so WaitAsync sees a completed task and never fires the timeout.
var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
try
{
return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
// WaitAsync's synthesized timeout — the inner task may still be running
// on its thread-pool thread (known leak documented in the class summary).
// Wrap so callers can distinguish from user-written timeout logic.
throw new ScriptTimeoutException(Timeout);
}
}
}
/// <summary>
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
/// engine (Stream B) catches this + maps the owning tag's quality to
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
/// warning with the offending script name so operators can locate + fix it.
/// </summary>
public sealed class ScriptTimeoutException : Exception
{
public TimeSpan Timeout { get; }
public ScriptTimeoutException(TimeSpan timeout)
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
"around the timeout and consider widening the timeout per tag, simplifying the script, or " +
"moving heavy work out of the evaluation path.")
{
Timeout = timeout;
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Roslyn scripting API — compiles user C# snippets with a constrained ScriptOptions
allow-list so scripts can't reach Process/File/HttpClient/reflection. Per Phase 7
plan decisions #1 + #6. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.12.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,151 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
/// expensive step in the evaluator pipeline; this cache collapses redundant
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
/// callers never double-compile.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CompiledScriptCacheTests
{
private sealed class CompileCountingGate
{
public int Count;
}
[Fact]
public void First_call_compiles_and_caches()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
cache.Count.ShouldBe(0);
var e = cache.GetOrCompile("""return 42;""");
e.ShouldNotBeNull();
cache.Count.ShouldBe(1);
cache.Contains("""return 42;""").ShouldBeTrue();
}
[Fact]
public void Identical_source_returns_the_same_compiled_evaluator()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
var first = cache.GetOrCompile("""return 1;""");
var second = cache.GetOrCompile("""return 1;""");
ReferenceEquals(first, second).ShouldBeTrue();
cache.Count.ShouldBe(1);
}
[Fact]
public void Different_source_produces_different_evaluator()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
var a = cache.GetOrCompile("""return 1;""");
var b = cache.GetOrCompile("""return 2;""");
ReferenceEquals(a, b).ShouldBeFalse();
cache.Count.ShouldBe(2);
}
[Fact]
public void Whitespace_difference_misses_cache()
{
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
var cache = new CompiledScriptCache<FakeScriptContext, int>();
cache.GetOrCompile("""return 1;""");
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
cache.Count.ShouldBe(2);
}
[Fact]
public async Task Cached_evaluator_still_runs_correctly()
{
var cache = new CompiledScriptCache<FakeScriptContext, double>();
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
var ctx = new FakeScriptContext().Seed("In", 7.0);
// Run twice through the cache — both must return the same correct value.
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
.RunAsync(ctx, TestContext.Current.CancellationToken);
first.ShouldBe(21.0);
second.ShouldBe(21.0);
}
[Fact]
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
// First attempt — undefined identifier, compile throws.
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
// Retry with corrected source succeeds + caches.
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
cache.Count.ShouldBe(1);
}
[Fact]
public void Clear_drops_every_entry()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
cache.GetOrCompile("""return 1;""");
cache.GetOrCompile("""return 2;""");
cache.Count.ShouldBe(2);
cache.Clear();
cache.Count.ShouldBe(0);
cache.Contains("""return 1;""").ShouldBeFalse();
}
[Fact]
public void Concurrent_compiles_of_the_same_source_deduplicate()
{
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
// even when multiple threads race GetOrCompile against an empty cache.
// We can't directly count Roslyn compilations — but we can assert all
// concurrent callers see the same evaluator instance.
var cache = new CompiledScriptCache<FakeScriptContext, int>();
const string src = """return 99;""";
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
.ToArray();
Task.WhenAll(tasks).GetAwaiter().GetResult();
var firstInstance = tasks[0].Result;
foreach (var t in tasks)
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
cache.Count.ShouldBe(1);
}
[Fact]
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
{
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
// its own cache. The type-parametric design makes this the default without
// cross-contamination at the dictionary level.
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
intCache.GetOrCompile("""return 1;""");
boolCache.GetOrCompile("""return true;""");
intCache.Count.ShouldBe(1);
boolCache.Count.ShouldBe(1);
intCache.Contains("""return true;""").ShouldBeFalse();
boolCache.Contains("""return 1;""").ShouldBeFalse();
}
[Fact]
public void Null_source_throws_ArgumentNullException()
{
var cache = new CompiledScriptCache<FakeScriptContext, int>();
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
/// <summary>
/// In-memory <see cref="ScriptContext"/> 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.
/// </summary>
public sealed class FakeScriptContext : ScriptContext
{
public Dictionary<string, DataValueSnapshot> 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;
}
}

View File

@@ -0,0 +1,155 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Verifies the sink that mirrors script Error+ events to the main log at Warning
/// level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
/// while genuine script failures DO surface there so operators see them without
/// watching a separate log file.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogCompanionSinkTests
{
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
{
// Main logger captures companion forwards.
var mainSink = new CapturingSink();
var mainLogger = new LoggerConfiguration()
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
// Script logger fans out to scripts file (here: capture sink) + the companion sink.
var scriptSink = new CapturingSink();
var scriptLogger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Sink(scriptSink)
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
.CreateLogger();
return (scriptLogger, scriptSink, mainSink);
}
[Fact]
public void Info_event_lands_in_scripts_sink_but_not_in_main()
{
var (script, scriptSink, mainSink) = BuildPipeline();
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");
scriptSink.Events.Count.ShouldBe(1);
mainSink.Events.Count.ShouldBe(0);
}
[Fact]
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
{
var (script, scriptSink, mainSink) = BuildPipeline();
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");
scriptSink.Events.Count.ShouldBe(1);
mainSink.Events.Count.ShouldBe(0);
}
[Fact]
public void Error_event_mirrored_to_main_at_Warning_level()
{
var (script, scriptSink, mainSink) = BuildPipeline();
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
.Error("condition script failed");
scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
mainSink.Events.Count.ShouldBe(1);
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
}
[Fact]
public void Mirrored_event_includes_ScriptName_and_original_level()
{
var (script, _, mainSink) = BuildPipeline();
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
.Error("temp exceeded limit");
var forwarded = mainSink.Events[0];
forwarded.Properties.ShouldContainKey("ScriptName");
((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
forwarded.Properties.ShouldContainKey("OriginalLevel");
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
}
[Fact]
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
{
var (script, _, mainSink) = BuildPipeline();
var ex = new InvalidOperationException("user code threw");
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");
mainSink.Events.Count.ShouldBe(1);
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
}
[Fact]
public void Fatal_event_mirrored_just_like_Error()
{
var (script, _, mainSink) = BuildPipeline();
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
mainSink.Events.Count.ShouldBe(1);
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
}
[Fact]
public void Missing_ScriptName_property_falls_back_to_unknown()
{
var (_, _, mainSink) = BuildPipeline();
// Log without the ScriptName property to simulate a direct root-logger call
// that bypassed the factory (defensive — shouldn't normally happen).
var mainLogger = new LoggerConfiguration().CreateLogger();
var companion = new ScriptLogCompanionSink(Log.Logger);
// Build an event manually so we can omit the property.
var ev = new LogEvent(
timestamp: DateTimeOffset.UtcNow,
level: LogEventLevel.Error,
exception: null,
messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
properties: []);
// Direct test: sink should not throw + message should be well-formed.
Should.NotThrow(() => companion.Emit(ev));
}
[Fact]
public void Null_main_logger_rejected()
{
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
}
[Fact]
public void Custom_mirror_threshold_applied()
{
// Caller can raise the mirror threshold to Fatal if they want only
// catastrophic events in the main log.
var mainSink = new CapturingSink();
var mainLogger = new LoggerConfiguration()
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
var scriptLogger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
.CreateLogger();
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
mainSink.Events.Count.ShouldBe(1);
}
}

View File

@@ -0,0 +1,94 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Exercises the factory that creates per-script Serilog loggers with the
/// <c>ScriptName</c> structured property pre-bound. The property is what lets
/// Admin UI filter the scripts-*.log sink by which tag/alarm emitted each event.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLoggerFactoryTests
{
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
private sealed class CapturingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
[Fact]
public void Create_sets_ScriptName_structured_property()
{
var sink = new CapturingSink();
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
var factory = new ScriptLoggerFactory(root);
var logger = factory.Create("LineRate");
logger.Information("hello");
sink.Events.Count.ShouldBe(1);
var ev = sink.Events[0];
ev.Properties.ShouldContainKey(ScriptLoggerFactory.ScriptNameProperty);
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
}
[Fact]
public void Each_script_gets_its_own_property_value()
{
var sink = new CapturingSink();
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
var factory = new ScriptLoggerFactory(root);
factory.Create("Alarm_A").Information("event A");
factory.Create("Tag_B").Warning("event B");
factory.Create("Alarm_A").Error("event A again");
sink.Events.Count.ShouldBe(3);
((ScalarValue)sink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
((ScalarValue)sink.Events[1].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Tag_B");
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
}
[Fact]
public void Error_level_event_preserves_level_and_exception()
{
var sink = new CapturingSink();
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
var factory = new ScriptLoggerFactory(root);
factory.Create("Test").Error(new InvalidOperationException("boom"), "script failed");
sink.Events[0].Level.ShouldBe(LogEventLevel.Error);
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
}
[Fact]
public void Null_root_rejected()
{
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
}
[Fact]
public void Empty_script_name_rejected()
{
var root = new LoggerConfiguration().CreateLogger();
var factory = new ScriptLoggerFactory(root);
Should.Throw<ArgumentException>(() => factory.Create(""));
Should.Throw<ArgumentException>(() => factory.Create(" "));
Should.Throw<ArgumentException>(() => factory.Create(null!));
}
[Fact]
public void ScriptNameProperty_constant_is_stable()
{
// Stability is an external contract — the Admin UI's log filter references
// this exact string. If it changes, the filter breaks silently.
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<FakeScriptContext, double>.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<FakeScriptContext, double>.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<FakeScriptContext, int>.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<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
}
[Fact]
public void Rejects_HttpClient_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var c = new System.Net.Http.HttpClient();
return 0;
"""));
}
[Fact]
public void Rejects_Process_Start_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Diagnostics.Process.Start("cmd.exe");
return 0;
"""));
}
[Fact]
public void Rejects_Reflection_Assembly_Load_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.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<FakeScriptContext, string?>.Compile(
"""return System.Environment.GetEnvironmentVariable("PATH");""");
evaluator.ShouldNotBeNull();
}
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""throw new InvalidOperationException("boom");""");
await Should.ThrowAsync<InvalidOperationException>(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<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
evaluator.ShouldNotBeNull();
}
[Fact]
public void Deadband_helper_is_reachable_from_scripts()
{
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.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<FakeScriptContext, int>.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<FakeScriptContext, bool>.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<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
Assert.Fail("expected CompilationErrorException");
}
catch (CompilationErrorException ex)
{
ex.Diagnostics.ShouldNotBeEmpty();
ex.Diagnostics[0].Location.ShouldNotBeNull();
}
}
}

View File

@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TimedScriptEvaluatorTests
{
[Fact]
public async Task Fast_script_completes_under_timeout_and_returns_value()
{
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""return (double)ctx.GetTag("In").Value + 1.0;""");
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
inner, TimeSpan.FromSeconds(1));
var ctx = new FakeScriptContext().Seed("In", 41.0);
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
result.ShouldBe(42.0);
}
[Fact]
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
{
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
// is denied). But a tight CPU loop exceeds any short timeout.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var end = Environment.TickCount64 + 5000;
while (Environment.TickCount64 < end) { }
return 1;
""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromMilliseconds(50));
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
ex.Message.ShouldContain("50.0");
}
[Fact]
public async Task Caller_cancellation_takes_precedence_over_timeout()
{
// A CPU-bound script that would otherwise timeout; external ct fires first.
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
// paths aren't misclassified as timeouts.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var end = Environment.TickCount64 + 10000;
while (Environment.TickCount64 < end) { }
return 1;
""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromSeconds(5));
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await timed.RunAsync(new FakeScriptContext(), cts.Token));
}
[Fact]
public void Default_timeout_is_250ms_per_plan()
{
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
.ShouldBe(TimeSpan.FromMilliseconds(250));
}
[Fact]
public void Zero_or_negative_timeout_is_rejected_at_construction()
{
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
Should.Throw<ArgumentOutOfRangeException>(() =>
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
Should.Throw<ArgumentOutOfRangeException>(() =>
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
}
[Fact]
public void Null_inner_is_rejected()
{
Should.Throw<ArgumentNullException>(() =>
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
}
[Fact]
public void Null_context_is_rejected()
{
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
Should.ThrowAsync<ArgumentNullException>(async () =>
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
// User-thrown exceptions must come through as-is — NOT wrapped in
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
// maps to BadInternalError; conflating with timeout would lose that info.
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""throw new InvalidOperationException("script boom");""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
ex.Message.ShouldBe("script boom");
}
[Fact]
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
{
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var end = Environment.TickCount64 + 5000;
while (Environment.TickCount64 < end) { }
return 1;
""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
inner, TimeSpan.FromMilliseconds(30));
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ctx.Logger");
ex.Message.ShouldContain("widening the timeout");
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>