using System.Text.Json; using ScadaLink.Commons.Types.Flattening; namespace ScadaLink.TemplateEngine.Validation; /// /// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness /// before deployment. Also available on-demand (same logic, no deployment trigger). /// /// Validation checks: /// 1. Flattening success (no empty configuration) /// 2. No naming collisions /// 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. 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; /// /// Initializes a new instance of the ValidationService with the specified dependencies. /// /// The semantic validator for configuration validation. /// The script compiler for validating script code. public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler) { _semanticValidator = semanticValidator; _scriptCompiler = scriptCompiler; } /// /// Convenience constructor that creates default dependencies. /// public ValidationService() : this(new SemanticValidator(), new ScriptCompiler()) { } /// /// Runs the full validation pipeline on a flattened configuration. /// /// The flattened configuration to validate. /// Optional list of shared scripts for validation context. public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null) { ArgumentNullException.ThrowIfNull(configuration); var results = new List { ValidateFlatteningSuccess(configuration), ValidateNamingCollisions(configuration), ValidateScriptCompilation(configuration), ValidateAlarmTriggerReferences(configuration), ValidateScriptTriggerReferences(configuration), ValidateExpressionTriggers(configuration), ValidateConnectionBindingCompleteness(configuration), _semanticValidator.Validate(configuration, sharedScripts) }; return ValidationResult.Merge(results.ToArray()); } /// /// Validates that flattening produced a non-empty configuration. /// /// The flattened configuration to validate. public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration) { var errors = new List(); if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName)) errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure, "Instance unique name is missing.")); if (configuration.Attributes.Count == 0 && configuration.Alarms.Count == 0 && configuration.Scripts.Count == 0) { return new ValidationResult { Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure, "Flattened configuration contains no attributes, alarms, or scripts.")] }; } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that there are no naming collisions across entity types. /// Canonical names must be unique within their entity type (attributes, alarms, scripts). /// /// The flattened configuration to validate. public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration) { var errors = new List(); CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors); CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors); CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors); return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that all scripts compile successfully using the ScriptCompiler. /// /// The flattened configuration to validate. public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); foreach (var script in configuration.Scripts) { var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName); if (!result.IsSuccess) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation, $"Script '{script.CanonicalName}' failed compilation: {result.Error}", script.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } /// /// Validates that alarm trigger configurations reference existing attributes. /// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name. /// /// The flattened configuration to validate. public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) { if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference, $"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.", alarm.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that script trigger configurations reference existing attributes. /// /// The flattened configuration to validate. public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var script in configuration.Scripts) { if (string.IsNullOrWhiteSpace(script.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference, $"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.", script.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : 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. /// /// /// The flattened configuration to validate. 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( ValidationCategory.ScriptTriggerReference, "script", script.CanonicalName, script.TriggerConfiguration, attributeNames, errors, warnings); } foreach (var alarm in configuration.Alarms) { if (!IsExpressionTrigger(alarm.TriggerType)) continue; CheckExpressionTrigger( ValidationCategory.AlarmTriggerReference, "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. /// /// /// The to file every finding under /// ( for scripts, /// 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. /// /// /// Human-readable entity-type label ("script"/"alarm") used in /// message text only. /// private static void CheckExpressionTrigger( ValidationCategory category, string entityLabel, string entityName, string? triggerConfigJson, HashSet attributeNames, List errors, List warnings) { var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson); if (string.IsNullOrWhiteSpace(expression)) { warnings.Add(ValidationEntry.Warning(category, $"The {entityLabel} '{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(category, $"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}", entityName)); } foreach (var attrName in ExtractAttributeReferences(expression)) { if (!attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(category, $"The {entityLabel} '{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. /// /// The trigger configuration JSON to parse. 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) && prop.ValueKind == JsonValueKind.String) { 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. /// /// The expression to check for syntax errors. internal static string? CheckExpressionSyntax(string expression) { // Advisory forbidden-API scan (TemplateEngine-006): code-region-aware so // the inert text inside a string/comment is not flagged, but still a // substring match — not an authoritative boundary. See ScriptCompiler. foreach (var pattern in ScriptCompiler.ForbiddenPatterns) { if (CSharpDelimiterScanner.ContainsInCode(expression, pattern)) { 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; 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) { if (c == '\\') { i++; continue; } if (c == '"') inString = false; continue; } if (inChar) { if (c == '\\') { i++; continue; } if (c == '\'') inChar = false; continue; } if (c == '/' && next == '/') { inLineComment = true; i++; continue; } if (c == '/' && next == '*') { inBlockComment = true; i++; 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 (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)."; 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. /// /// The expression to scan for attribute references. 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) { // Only treat this as a self-attribute reference when it is not a member // access. A bare `Attributes["X"]` resolves against the flattened // configuration; `Children["Pump"].Attributes["X"]` and // `Parent.Attributes["X"]` are member accesses (preceded by '.') whose // dotted/composed canonical names cannot be checked against the flat // self-attribute set — skip them rather than emit a false positive. if (index > 0 && expression[index - 1] == '.') { index += marker.Length; continue; } 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. /// /// The flattened configuration to validate. public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); foreach (var attr in configuration.Attributes) { if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null) { warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding, $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.", attr.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static void CheckDuplicates( IReadOnlyList items, Func getName, string entityType, List errors) { var seen = new HashSet(StringComparer.Ordinal); foreach (var item in items) { var name = getName(item); if (!seen.Add(name)) { errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision, $"{entityType} naming collision: '{name}' appears more than once.", name)); } } } /// /// Extracts the attribute name from a trigger configuration JSON. /// /// The trigger configuration JSON to parse. internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson) { // Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute, // AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use // "attribute"; the UI codec writes the canonical "attributeName". try { using var doc = JsonDocument.Parse(triggerConfigJson); if (doc.RootElement.TryGetProperty("attributeName", out var prop)) return prop.GetString(); if (doc.RootElement.TryGetProperty("attribute", out var legacyProp)) return legacyProp.GetString(); } catch (JsonException) { // Not valid JSON, ignore } return null; } /// /// Extracts the four HiLo setpoints from a trigger configuration JSON. /// Any unset (or non-numeric) setpoint comes back as null. Returns /// all-nulls on malformed JSON — callers should treat that as "nothing to /// validate" and let other checks surface the deeper problem. /// /// The trigger configuration JSON to parse. internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson) { try { using var doc = JsonDocument.Parse(triggerConfigJson); var root = doc.RootElement; return new HiLoSetpoints( LoLo: ReadDouble(root, "loLo"), Lo: ReadDouble(root, "lo"), Hi: ReadDouble(root, "hi"), HiHi: ReadDouble(root, "hiHi"), LoLoDeadband: ReadDouble(root, "loLoDeadband"), LoDeadband: ReadDouble(root, "loDeadband"), HiDeadband: ReadDouble(root, "hiDeadband"), HiHiDeadband: ReadDouble(root, "hiHiDeadband")); } catch (JsonException) { return new HiLoSetpoints(null, null, null, null, null, null, null, null); } } private static double? ReadDouble(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number => p.GetDouble(), JsonValueKind.String when double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) => v, _ => null }; } } internal readonly record struct HiLoSetpoints( double? LoLo, double? Lo, double? Hi, double? HiHi, double? LoLoDeadband = null, double? LoDeadband = null, double? HiDeadband = null, double? HiHiDeadband = null);