feat(triggers): validate expression triggers pre-deployment
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user