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.
///