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