From bf3f572ad9ebdd7fcbc922e7e3b1d2c9558fa391 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 05:52:25 -0400 Subject: [PATCH] feat(triggers): validate expression triggers pre-deployment --- .../Validation/ValidationService.cs | 251 +++++++++++++++++- 1 file changed, 249 insertions(+), 2 deletions(-) diff --git a/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs b/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs index cd2053c..9b85423 100644 --- a/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs +++ b/src/ScadaLink.TemplateEngine/Validation/ValidationService.cs @@ -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 /// public class ValidationService { private readonly SemanticValidator _semanticValidator; private readonly ScriptCompiler _scriptCompiler; + /// + /// Forbidden namespace patterns for trigger expressions. Mirrors the list in + /// — trigger expressions run under the same trust + /// model as scripts. + /// + 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(); } + /// + /// Validates Expression-trigger scripts and alarms before deployment. + /// + /// For every script/alarm whose trigger type is "Expression" this performs three + /// checks against the { "expression": "..." } trigger configuration: + /// + /// Blank expression → warning (the trigger will never fire). + /// 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 ), so this mirrors that + /// string-based syntax check rather than a full compile. + /// Attribute-reference scan → error for any Attributes["X"] literal + /// whose key is absent from the flattened configuration, mirroring + /// for the structured triggers. + /// + /// + public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration) + { + var errors = new List(); + var warnings = new List(); + var attributeNames = new HashSet( + 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); + + /// + /// Runs the blank / syntax / attribute-reference checks for a single + /// Expression-trigger entity and appends any findings to the shared lists. + /// + private static void CheckExpressionTrigger( + string entityType, + string entityName, + string? triggerConfigJson, + HashSet attributeNames, + List errors, + List 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)); + } + } + } + + /// + /// Reads the "expression" string from a { "expression": "..." } trigger + /// configuration. Returns null on malformed JSON or a missing key. + /// + 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; + } + + /// + /// Lightweight string-based syntax check for a trigger expression. Mirrors the + /// approach in (the TemplateEngine project has no + /// Roslyn compiler reference): rejects forbidden APIs and unbalanced + /// brackets/quotes. Returns an error message, or null when the expression + /// looks well-formed. + /// + 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; + } + + /// + /// Scans an expression for Attributes["..."] string-literal accessor keys. + /// Best-effort: only matches double-quoted literals (the form the editor emits) + /// and skips keys built dynamically. + /// + internal static IEnumerable ExtractAttributeReferences(string expression) + { + var seen = new HashSet(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; + } + } + /// /// Validates that all data-sourced attributes have connection bindings. ///