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:
Joseph Doherty
2026-05-23 18:00:59 -04:00
parent c2abbf45bd
commit 23d59d73f2
8 changed files with 311 additions and 29 deletions

View File

@@ -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

View File

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

View File

@@ -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('+', '.');