fix(scriptanalysis): memoise ScriptTrustValidator verdicts by code hash for the default reference set (review 2026-10-21 04/P3)

This commit is contained in:
Joseph Doherty
2026-10-22 06:18:15 -04:00
parent ae1282ed9d
commit 8c935480fa
4 changed files with 170 additions and 2 deletions
@@ -68,11 +68,26 @@ public static class ScriptTrustValidator
/// forwards its full compilation reference surface here precisely because
/// doing so is safe.
/// </param>
/// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
/// <returns>
/// A read-only list of trust-model violation messages; empty if the script is clean.
/// When <paramref name="extraReferences"/> is null or empty the verdict is memoised
/// process-wide by code hash (a repeat call returns the same instance); a call with
/// extra references always analyses afresh, since they can change resolution.
/// </returns>
public static IReadOnlyList<string> FindViolations(
string code,
IEnumerable<MetadataReference>? extraReferences = null)
=> FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extraReferences);
{
var extras = extraReferences as IReadOnlyCollection<MetadataReference> ?? extraReferences?.ToList();
if (extras is { Count: > 0 } || string.IsNullOrWhiteSpace(code))
return FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extras);
// Review 2026-10-21 04/P3: the default-reference verdict is a pure function of
// the code, so memoise it — the importer's preview and apply passes otherwise
// re-run a full-framework semantic analysis for every script, every time.
return TrustVerdictCache.GetOrAdd(code,
() => FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extraReferences: null));
}
/// <summary>
/// Overload that runs the trust analysis against an explicit base reference
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
/// <summary>
/// Process-wide memo of <see cref="ScriptTrustValidator"/> verdicts for the
/// default policy reference set, keyed on the SHA-256 of the script code (review
/// 2026-10-21 04/P3).
///
/// <para>
/// A verdict is a pure function of (code, <see cref="ScriptTrustPolicy"/>,
/// <see cref="ScriptTrustPolicy.AnalysisReferences"/>), and the policy and
/// reference set are process-static, so the code hash alone identifies it. Each
/// miss is a full-framework semantic analysis (~170 references) — the bundle
/// importer runs the same script set through it on preview and again on apply,
/// and the wizard re-enters preview — so memoising here removes that repeat cost
/// without any change at the call sites.
/// </para>
///
/// <para>
/// Bounded like the Template Engine's compile-verdict cache: two generations of
/// <see cref="SegmentCapacity"/> entries; a hit in the cold generation is promoted,
/// and overflow drops only the cold half nothing touched for a whole generation.
/// Only completed verdicts are stored — an analysis that throws leaves the slot
/// empty. Stored verdicts are read-only collections, so a caller cannot mutate a
/// shared entry.
/// </para>
/// </summary>
internal static class TrustVerdictCache
{
/// <summary>Upper bound on entries per generation (2 × this in total).</summary>
internal const int SegmentCapacity = 1024;
private static readonly object RotateGate = new();
private static volatile ConcurrentDictionary<string, IReadOnlyList<string>> _hot = new();
private static volatile ConcurrentDictionary<string, IReadOnlyList<string>> _cold = new();
/// <summary>
/// Returns the cached verdict for <paramref name="code"/>, or computes it with
/// <paramref name="analyse"/> and caches it on a miss.
/// </summary>
/// <param name="code">The script source.</param>
/// <param name="analyse">Computes the verdict on a miss; a throw is not cached.</param>
/// <returns>The (read-only) violation list.</returns>
internal static IReadOnlyList<string> GetOrAdd(string code, Func<IReadOnlyList<string>> analyse)
{
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
var hot = _hot;
var cold = _cold;
if (hot.TryGetValue(key, out var verdict))
return verdict;
if (cold.TryGetValue(key, out verdict))
{
Store(key, verdict);
return verdict;
}
verdict = analyse().ToList().AsReadOnly();
Store(key, verdict);
return verdict;
}
private static void Store(string key, IReadOnlyList<string> verdict)
{
if (_hot.Count >= SegmentCapacity)
{
lock (RotateGate)
{
if (_hot.Count >= SegmentCapacity)
{
_cold = _hot;
_hot = new ConcurrentDictionary<string, IReadOnlyList<string>>();
}
}
}
// TryAdd, not an indexer write: a racing thread that computed the same verdict
// keeps the instance already published, so every reader shares one object.
_hot.TryAdd(key, verdict);
}
}