feat(triggers): validate expression triggers pre-deployment

This commit is contained in:
Joseph Doherty
2026-05-16 05:52:25 -04:00
parent 3499d76f14
commit bf3f572ad9

View File

@@ -13,14 +13,30 @@ namespace ScadaLink.TemplateEngine.Validation;
/// 3. Script compilation (via ScriptCompiler)
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Connection binding completeness (all data-sourced attributes must have a binding)
/// 7. Does NOT verify tag path resolution on devices
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness (all data-sourced attributes must have a binding)
/// 8. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
private readonly SemanticValidator _semanticValidator;
private readonly ScriptCompiler _scriptCompiler;
/// <summary>
/// Forbidden namespace patterns for trigger expressions. Mirrors the list in
/// <see cref="ScriptCompiler"/> — trigger expressions run under the same trust
/// model as scripts.
/// </summary>
private static readonly string[] ForbiddenExpressionPatterns =
[
"System.IO.",
"System.Diagnostics.Process",
"System.Threading.",
"System.Reflection.",
"System.Net.Sockets.",
"System.Net.Http.",
];
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
{
_semanticValidator = semanticValidator;
@@ -48,6 +64,7 @@ public class ValidationService
ValidateScriptCompilation(configuration),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration),
_semanticValidator.Validate(configuration, sharedScripts)
};
@@ -178,6 +195,236 @@ public class ValidationService
: ValidationResult.Success();
}
/// <summary>
/// Validates Expression-trigger scripts and alarms before deployment.
///
/// For every script/alarm whose trigger type is "Expression" this performs three
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
/// <list type="bullet">
/// <item>Blank expression → warning (the trigger will never fire).</item>
/// <item>Syntax check → error if the expression uses a forbidden API or has
/// unbalanced brackets/quotes. The TemplateEngine project does not reference a
/// Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
/// string-based syntax check rather than a full compile.</item>
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
/// whose key is absent from the flattened configuration, mirroring
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
/// </list>
/// </summary>
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (!IsExpressionTrigger(script.TriggerType))
continue;
CheckExpressionTrigger(
"Script", script.CanonicalName, script.TriggerConfiguration,
attributeNames, errors, warnings);
}
foreach (var alarm in configuration.Alarms)
{
if (!IsExpressionTrigger(alarm.TriggerType))
continue;
CheckExpressionTrigger(
"Alarm", alarm.CanonicalName, alarm.TriggerConfiguration,
attributeNames, errors, warnings);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static bool IsExpressionTrigger(string? triggerType) =>
string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Runs the blank / syntax / attribute-reference checks for a single
/// Expression-trigger entity and appends any findings to the shared lists.
/// </summary>
private static void CheckExpressionTrigger(
string entityType,
string entityName,
string? triggerConfigJson,
HashSet<string> attributeNames,
List<ValidationEntry> errors,
List<ValidationEntry> warnings)
{
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
if (string.IsNullOrWhiteSpace(expression))
{
warnings.Add(ValidationEntry.Warning(
entityType == "Alarm"
? ValidationCategory.AlarmTriggerReference
: ValidationCategory.ScriptTriggerReference,
$"{entityType} '{entityName}' has an expression trigger with no expression; it will never fire.",
entityName));
return;
}
var syntaxError = CheckExpressionSyntax(expression);
if (syntaxError != null)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
$"{entityType} '{entityName}' expression trigger failed validation: {syntaxError}",
entityName));
}
foreach (var attrName in ExtractAttributeReferences(expression))
{
if (!attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(
entityType == "Alarm"
? ValidationCategory.AlarmTriggerReference
: ValidationCategory.ScriptTriggerReference,
$"{entityType} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
entityName));
}
}
}
/// <summary>
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
/// </summary>
internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
{
if (string.IsNullOrWhiteSpace(triggerConfigJson))
return null;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("expression", out var prop))
return prop.GetString();
}
catch (JsonException)
{
// Not valid JSON — treated as a blank expression by the caller.
}
return null;
}
/// <summary>
/// Lightweight string-based syntax check for a trigger expression. Mirrors the
/// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
/// Roslyn compiler reference): rejects forbidden APIs and unbalanced
/// brackets/quotes. Returns an error message, or <c>null</c> when the expression
/// looks well-formed.
/// </summary>
internal static string? CheckExpressionSyntax(string expression)
{
foreach (var pattern in ForbiddenExpressionPatterns)
{
if (expression.Contains(pattern, StringComparison.Ordinal))
{
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
}
}
var parenDepth = 0;
var bracketDepth = 0;
var braceDepth = 0;
var inString = false;
var inChar = false;
for (int i = 0; i < expression.Length; i++)
{
var c = expression[i];
if (inString)
{
if (c == '\\') { i++; continue; }
if (c == '"') inString = false;
continue;
}
if (inChar)
{
if (c == '\\') { i++; continue; }
if (c == '\'') inChar = false;
continue;
}
switch (c)
{
case '"': inString = true; break;
case '\'': inChar = true; break;
case '(': parenDepth++; break;
case ')':
parenDepth--;
if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
break;
case '[': bracketDepth++; break;
case ']':
bracketDepth--;
if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
break;
case '{': braceDepth++; break;
case '}':
braceDepth--;
if (braceDepth < 0) return "mismatched braces (unexpected '}').";
break;
}
}
if (inString) return "unterminated string literal.";
if (inChar) return "unterminated character literal.";
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";
return null;
}
/// <summary>
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
/// Best-effort: only matches double-quoted literals (the form the editor emits)
/// and skips keys built dynamically.
/// </summary>
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
const string marker = "Attributes[";
var index = 0;
while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
{
var cursor = index + marker.Length;
// Skip whitespace between '[' and the literal.
while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
cursor++;
if (cursor < expression.Length && expression[cursor] == '"')
{
var keyStart = cursor + 1;
var keyEnd = keyStart;
while (keyEnd < expression.Length && expression[keyEnd] != '"')
{
if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
keyEnd++;
}
if (keyEnd < expression.Length)
{
var key = expression.Substring(keyStart, keyEnd - keyStart);
if (key.Length > 0 && seen.Add(key))
yield return key;
}
}
index += marker.Length;
}
}
/// <summary>
/// Validates that all data-sourced attributes have connection bindings.
/// </summary>