fix(scripting+alarms): close remaining re-review findings
Single commit covering the four small/medium fixes from the updated code review. Core.Scripting-014 (Medium, Concurrency): CompiledScriptCache.Clear() used the key-only TryRemove(key, out var lazy) overload — same race shape Core.Scripting-006 closed in GetOrCompile's catch block. A concurrent re-add between snapshot and TryRemove was evicted + disposed while the new caller still held it. Replaced with the value-scoped TryRemove(KeyValuePair<,>) overload. Regression test Clear_uses_value_scoped_TryRemove_so_a_race_inserted_entry_survives added. Core.Scripting-013 (Medium, Security): Hand-rolled BuildWrapperSource pastes user source between literal braces; brace-balanced source could inject sibling methods/classes alongside CompiledScript.Run. Analyzer still walked the injected members so it wasn't a direct escape, but it relaxed the documented 'method body' authoring contract. Added EnforceSingleRunMember: after ParseText, the compilation unit must hold exactly one type (CompiledScript) and that type must hold exactly one member (the Run method). Any deviation throws CompilationErrorException with LMX001/ LMX002 diagnostic IDs and a Core.Scripting-013 reference in the message. Two regression tests added covering the sibling-method and sibling-class injection vectors. Core.Scripting-015 (Low, Correctness, latent): ToCSharpTypeName's generic branch truncated at the first backtick via IndexOf, silently dropping closed args of nested-generic shapes (Outer<T>.Inner<U>). No production caller exercises this shape today (all TContext/TResult are top-level non-nested), so the bug was latent. Rewrote the generic branch to walk the FullName segment-by- segment, consuming generic args per segment so nested shapes emit valid C# (global::Ns.Outer<T>.Inner<U> rather than the broken Outer<T,U>). Core.ScriptedAlarms-013 (Low, Documentation): The internal test accessors TryGetScratchReadCacheForTest / TryGetScratchContextForTest return live mutable scratch refilled in place under _evalGate. XML docs didn't warn future test authors about the synchronization contract. Added a <remarks> block to each documenting the only-safe-on-quiesced-engine + identity-or-single-key contract. Verification (suites green): Core.Scripting.Tests: 110/110 (was 107 — +3 new rejection/race tests) Core.ScriptedAlarms.Tests: 67/67 (unchanged — doc-only fix) Core.VirtualTags.Tests: 57/57 (unchanged) After this commit, all 12 findings from the updated re-review are closed (10 Resolved, 1 Won't Fix none, 1 Deferred — Driver.Galaxy-017). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,19 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// regression tests to assert the scratch is reused across evaluations
|
||||
/// (two reads return the same instance).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Synchronization:</b> the returned <see cref="IReadOnlyDictionary{TKey, TValue}"/>
|
||||
/// is the engine's live mutable read-cache. It is refilled in place by
|
||||
/// <c>RefillReadCache</c> on every predicate evaluation, under <c>_evalGate</c>.
|
||||
/// Test callers MUST NOT iterate this dictionary while the engine is
|
||||
/// actively evaluating (i.e. while an upstream change is mid-flight); the
|
||||
/// refill clears the dict before repopulating and a concurrent iterator
|
||||
/// would observe torn / partial state. Safe uses are: reference-identity
|
||||
/// comparisons (e.g. asserting the same instance is reused across calls),
|
||||
/// and single-key reads against an engine that has quiesced after a
|
||||
/// deterministic upstream push. Anything more involved should snapshot a
|
||||
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
||||
|
||||
@@ -91,6 +104,13 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// if one has been allocated, else null. Companion to
|
||||
/// <see cref="TryGetScratchReadCacheForTest"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Synchronization:</b> the returned context wraps the same live
|
||||
/// read-cache as <see cref="TryGetScratchReadCacheForTest"/> — the same
|
||||
/// "don't iterate during an in-flight evaluation" caveat applies. Safe
|
||||
/// for reference-identity assertions on a quiesced engine.
|
||||
/// (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||
|
||||
@@ -99,14 +99,19 @@ public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||
/// </remarks>
|
||||
public void Clear()
|
||||
{
|
||||
// Snapshot the entries, swap them out, then dispose. We use TryRemove rather
|
||||
// than _cache.Clear() so a concurrent GetOrCompile re-add after our snapshot
|
||||
// is not silently lost — a new compile starts a fresh cache entry, the old
|
||||
// evaluator is still disposed.
|
||||
foreach (var key in _cache.Keys.ToArray())
|
||||
// Snapshot (key, value) pairs and remove with the value-scoped
|
||||
// TryRemove(KeyValuePair<,>) overload — same shape as the
|
||||
// Core.Scripting-006 fix in GetOrCompile's catch block. A concurrent
|
||||
// GetOrCompile re-add that hashes to the same key between our snapshot
|
||||
// and the TryRemove inserts a *different* Lazy reference; the value-
|
||||
// scoped removal sees the mismatch and leaves the fresh entry intact
|
||||
// (instead of evicting + disposing it while the concurrent caller
|
||||
// still holds it). The fresh evaluator and its ALC stay live for the
|
||||
// concurrent caller. (Core.Scripting-014.)
|
||||
foreach (var entry in _cache.ToArray())
|
||||
{
|
||||
if (_cache.TryRemove(key, out var lazy))
|
||||
DisposeLazyIfMaterialised(lazy);
|
||||
if (_cache.TryRemove(entry))
|
||||
DisposeLazyIfMaterialised(entry.Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,17 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
var wrapperSource = BuildWrapperSource(scriptSource, sandbox.Imports);
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(wrapperSource);
|
||||
|
||||
// Step 1a — defend against wrapper-source injection (Core.Scripting-013).
|
||||
// A script body of `return 0; } public static int Evil() { return 0;` would
|
||||
// close the synthesized `Run` method early, declare a sibling `Evil` method
|
||||
// inside the synthesized `CompiledScript` class, and leave the wrapper's
|
||||
// trailing `}` balanced. ForbiddenTypeAnalyzer still walks the injected
|
||||
// members so this isn't a direct sandbox escape, but it relaxes the
|
||||
// documented "method body" authoring contract and widens the analyzer's
|
||||
// surface. Reject by requiring that the parsed `CompiledScript` class
|
||||
// contains exactly one member declaration (the `Run` method).
|
||||
EnforceSingleRunMember(syntaxTree);
|
||||
|
||||
// Step 2 — Roslyn compile against the sandbox allow-list. Anything not in the
|
||||
// references set is unresolved and produces a compiler error.
|
||||
var assemblyName = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled." +
|
||||
@@ -193,6 +204,70 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
_alc.Unload();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject scripts whose source contains brace-balanced injections that would
|
||||
/// declare sibling members alongside the synthesized <c>CompiledScript.Run</c>
|
||||
/// method. The expected shape is a single <c>CompiledScript</c> class with
|
||||
/// exactly one member — the <c>Run</c> method. Anything else (a sibling
|
||||
/// method, nested class, additional class in the namespace, free-floating
|
||||
/// top-level statement) means the user source closed the synthesized braces
|
||||
/// early and injected its own declarations. (Core.Scripting-013.)
|
||||
/// </summary>
|
||||
private static void EnforceSingleRunMember(SyntaxTree syntaxTree)
|
||||
{
|
||||
var root = syntaxTree.GetCompilationUnitRoot();
|
||||
|
||||
// The compilation unit must hold exactly one type declaration — our
|
||||
// CompiledScript. Anything else means the user closed the synthesized
|
||||
// namespace or class early and injected another type declaration.
|
||||
var typeMembers = root.DescendantNodes()
|
||||
.OfType<Microsoft.CodeAnalysis.CSharp.Syntax.BaseTypeDeclarationSyntax>()
|
||||
.ToArray();
|
||||
if (typeMembers.Length != 1 || typeMembers[0].Identifier.ValueText != "CompiledScript")
|
||||
{
|
||||
throw new CompilationErrorException(new[]
|
||||
{
|
||||
Diagnostic.Create(
|
||||
new DiagnosticDescriptor(
|
||||
id: "LMX001",
|
||||
title: "Script wrapper injection",
|
||||
messageFormat: "Script source must be a statement body. Declarations of " +
|
||||
"additional types alongside the wrapper's CompiledScript class " +
|
||||
"are not allowed; check for unbalanced braces or stray " +
|
||||
"`class` / `namespace` keywords in the source. (Core.Scripting-013)",
|
||||
category: "Sandbox",
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true),
|
||||
typeMembers.Length > 1 ? typeMembers[1].Identifier.GetLocation() : Location.None),
|
||||
});
|
||||
}
|
||||
|
||||
// The CompiledScript class itself must contain exactly one member — the Run
|
||||
// method. A second member means the user closed Run early and started a sibling.
|
||||
var classMembers = ((Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax)typeMembers[0]).Members;
|
||||
if (classMembers.Count != 1 ||
|
||||
classMembers[0] is not Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax m ||
|
||||
m.Identifier.ValueText != "Run")
|
||||
{
|
||||
throw new CompilationErrorException(new[]
|
||||
{
|
||||
Diagnostic.Create(
|
||||
new DiagnosticDescriptor(
|
||||
id: "LMX002",
|
||||
title: "Script wrapper injection",
|
||||
messageFormat: "Script source must be a statement body. Declarations of " +
|
||||
"sibling members (methods, properties, nested types) alongside " +
|
||||
"the wrapper's Run method are not allowed; check for unbalanced " +
|
||||
"braces or a stray `}` followed by a `public`/`private`/`static` " +
|
||||
"declaration in the source. (Core.Scripting-013)",
|
||||
category: "Sandbox",
|
||||
defaultSeverity: DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true),
|
||||
classMembers.Count > 1 ? classMembers[1].GetLocation() : Location.None),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesize the source we hand to Roslyn. The user's script body is pasted
|
||||
/// verbatim inside <c>CompiledScript.Run</c>; the <c>using</c> directives mirror
|
||||
@@ -259,11 +334,41 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
|
||||
if (t.IsGenericType)
|
||||
{
|
||||
var def = t.GetGenericTypeDefinition();
|
||||
var rawName = def.FullName!.Replace('+', '.');
|
||||
var nameNoArity = rawName.Substring(0, rawName.IndexOf('`'));
|
||||
var args = string.Join(", ", t.GetGenericArguments().Select(ToCSharpTypeName));
|
||||
return "global::" + nameNoArity + "<" + args + ">";
|
||||
// Walk the FullName by '.' segments (after '+ → .'). For each segment
|
||||
// ending with `Name\`N`, consume N generic arguments and emit them as
|
||||
// `<…>` on that segment. Nested generic-in-generic (Outer<T>.Inner<U>)
|
||||
// emits as `global::Ns.Outer<T>.Inner<U>` — valid C#. The pre-fix code
|
||||
// used `IndexOf('`')` to find the FIRST backtick and truncated the
|
||||
// entire name there, silently dropping the rest of the nested-generic
|
||||
// closed args. (Core.Scripting-015.)
|
||||
var rawName = t.GetGenericTypeDefinition().FullName!.Replace('+', '.');
|
||||
var allArgs = t.GetGenericArguments();
|
||||
var segments = rawName.Split('.');
|
||||
var argIndex = 0;
|
||||
var sb = new StringBuilder("global::");
|
||||
for (int i = 0; i < segments.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append('.');
|
||||
var seg = segments[i];
|
||||
var backtick = seg.IndexOf('`');
|
||||
if (backtick >= 0)
|
||||
{
|
||||
var arity = int.Parse(seg.AsSpan(backtick + 1));
|
||||
sb.Append(seg, 0, backtick);
|
||||
sb.Append('<');
|
||||
for (int j = 0; j < arity; j++)
|
||||
{
|
||||
if (j > 0) sb.Append(", ");
|
||||
sb.Append(ToCSharpTypeName(allArgs[argIndex++]));
|
||||
}
|
||||
sb.Append('>');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(seg);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
return "global::" + t.FullName!.Replace('+', '.');
|
||||
|
||||
Reference in New Issue
Block a user