fix(triggers): use explicit ValidationCategory + tighten expression syntax validation
This commit is contained in:
@@ -12,8 +12,13 @@ namespace ScadaLink.TemplateEngine.Validation;
|
||||
/// </summary>
|
||||
public class ScriptCompiler
|
||||
{
|
||||
// Forbidden namespace patterns - scripts must not use these
|
||||
private static readonly string[] ForbiddenPatterns =
|
||||
/// <summary>
|
||||
/// Forbidden namespace patterns — scripts (and trigger expressions, via
|
||||
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
|
||||
/// under the same trust model as scripts, so the list is shared from here rather
|
||||
/// than duplicated.
|
||||
/// </summary>
|
||||
internal static readonly string[] ForbiddenPatterns =
|
||||
[
|
||||
"System.IO.",
|
||||
"System.Diagnostics.Process",
|
||||
|
||||
@@ -22,21 +22,6 @@ 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;
|
||||
@@ -224,7 +209,8 @@ public class ValidationService
|
||||
continue;
|
||||
|
||||
CheckExpressionTrigger(
|
||||
"Script", script.CanonicalName, script.TriggerConfiguration,
|
||||
ValidationCategory.ScriptTriggerReference, "script",
|
||||
script.CanonicalName, script.TriggerConfiguration,
|
||||
attributeNames, errors, warnings);
|
||||
}
|
||||
|
||||
@@ -234,7 +220,8 @@ public class ValidationService
|
||||
continue;
|
||||
|
||||
CheckExpressionTrigger(
|
||||
"Alarm", alarm.CanonicalName, alarm.TriggerConfiguration,
|
||||
ValidationCategory.AlarmTriggerReference, "alarm",
|
||||
alarm.CanonicalName, alarm.TriggerConfiguration,
|
||||
attributeNames, errors, warnings);
|
||||
}
|
||||
|
||||
@@ -248,8 +235,20 @@ public class ValidationService
|
||||
/// Runs the blank / syntax / attribute-reference checks for a single
|
||||
/// Expression-trigger entity and appends any findings to the shared lists.
|
||||
/// </summary>
|
||||
/// <param name="category">
|
||||
/// The <see cref="ValidationCategory"/> to file every finding under
|
||||
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
|
||||
/// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
|
||||
/// category is used for blank, syntax, and attribute-reference findings so an
|
||||
/// alarm's syntax error is not miscategorised as script compilation.
|
||||
/// </param>
|
||||
/// <param name="entityLabel">
|
||||
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
|
||||
/// message text only.
|
||||
/// </param>
|
||||
private static void CheckExpressionTrigger(
|
||||
string entityType,
|
||||
ValidationCategory category,
|
||||
string entityLabel,
|
||||
string entityName,
|
||||
string? triggerConfigJson,
|
||||
HashSet<string> attributeNames,
|
||||
@@ -260,11 +259,8 @@ public class ValidationService
|
||||
|
||||
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.",
|
||||
warnings.Add(ValidationEntry.Warning(category,
|
||||
$"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
|
||||
entityName));
|
||||
return;
|
||||
}
|
||||
@@ -272,8 +268,8 @@ public class ValidationService
|
||||
var syntaxError = CheckExpressionSyntax(expression);
|
||||
if (syntaxError != null)
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
|
||||
$"{entityType} '{entityName}' expression trigger failed validation: {syntaxError}",
|
||||
errors.Add(ValidationEntry.Error(category,
|
||||
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
|
||||
entityName));
|
||||
}
|
||||
|
||||
@@ -281,11 +277,8 @@ public class ValidationService
|
||||
{
|
||||
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.",
|
||||
errors.Add(ValidationEntry.Error(category,
|
||||
$"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
|
||||
entityName));
|
||||
}
|
||||
}
|
||||
@@ -302,8 +295,11 @@ public class ValidationService
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
if (doc.RootElement.TryGetProperty("expression", out var prop))
|
||||
if (doc.RootElement.TryGetProperty("expression", out var prop)
|
||||
&& prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -321,7 +317,7 @@ public class ValidationService
|
||||
/// </summary>
|
||||
internal static string? CheckExpressionSyntax(string expression)
|
||||
{
|
||||
foreach (var pattern in ForbiddenExpressionPatterns)
|
||||
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
|
||||
{
|
||||
if (expression.Contains(pattern, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -335,10 +331,29 @@ public class ValidationService
|
||||
var braceDepth = 0;
|
||||
var inString = false;
|
||||
var inChar = false;
|
||||
var inLineComment = false;
|
||||
var inBlockComment = false;
|
||||
|
||||
for (int i = 0; i < expression.Length; i++)
|
||||
{
|
||||
var c = expression[i];
|
||||
var next = i + 1 < expression.Length ? expression[i + 1] : '\0';
|
||||
|
||||
if (inLineComment)
|
||||
{
|
||||
if (c == '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBlockComment)
|
||||
{
|
||||
if (c == '*' && next == '/')
|
||||
{
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString)
|
||||
{
|
||||
@@ -354,6 +369,20 @@ public class ValidationService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"': inString = true; break;
|
||||
@@ -376,6 +405,7 @@ public class ValidationService
|
||||
}
|
||||
}
|
||||
|
||||
if (inBlockComment) return "unterminated block comment.";
|
||||
if (inString) return "unterminated string literal.";
|
||||
if (inChar) return "unterminated character literal.";
|
||||
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
|
||||
|
||||
Reference in New Issue
Block a user