using System.Text.RegularExpressions; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; /// /// Managed-code pre-publish validator per decision #91. Complements the structural checks in /// sp_ValidateDraft — this layer owns schema validation for JSON columns, UNS segment /// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable /// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors, /// not just the first, so operators fix in bulk). /// public static class DraftValidator { private static readonly Regex UnsSegment = new(@"^[a-z0-9-]{1,32}$", RegexOptions.Compiled); private const string UnsDefaultSegment = "_default"; private const int MaxPathLength = 200; public static IReadOnlyList Validate(DraftSnapshot draft) { var errors = new List(); ValidateUnsSegments(draft, errors); ValidatePathLength(draft, errors); ValidateEquipmentUuidImmutability(draft, errors); ValidateSameClusterNamespaceBinding(draft, errors); ValidateReservationPreflight(draft, errors); ValidateEquipmentIdDerivation(draft, errors); ValidateDriverNamespaceCompatibility(draft, errors); return errors; } private static bool IsValidSegment(string? s) => s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); private static void ValidateUnsSegments(DraftSnapshot draft, List errors) { foreach (var a in draft.UnsAreas) if (!IsValidSegment(a.Name)) errors.Add(new("UnsSegmentInvalid", $"UnsArea.Name '{a.Name}' does not match [a-z0-9-]{{1,32}} or '_default'", a.UnsAreaId)); foreach (var l in draft.UnsLines) if (!IsValidSegment(l.Name)) errors.Add(new("UnsSegmentInvalid", $"UnsLine.Name '{l.Name}' does not match [a-z0-9-]{{1,32}} or '_default'", l.UnsLineId)); foreach (var e in draft.Equipment) if (!IsValidSegment(e.Name)) errors.Add(new("UnsSegmentInvalid", $"Equipment.Name '{e.Name}' does not match [a-z0-9-]{{1,32}} or '_default'", e.EquipmentId)); } /// Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars. private static void ValidatePathLength(DraftSnapshot draft, List errors) { // The cluster row isn't in the snapshot — we assume caller pre-validated Enterprise+Site // length and bound them as constants <= 64 chars each. Here we validate the dynamic portion. var areaById = draft.UnsAreas.ToDictionary(a => a.UnsAreaId); var lineById = draft.UnsLines.ToDictionary(l => l.UnsLineId); foreach (var eq in draft.Equipment.Where(e => e.UnsLineId is not null)) { if (!lineById.TryGetValue(eq.UnsLineId!, out var line)) continue; if (!areaById.TryGetValue(line.UnsAreaId, out var area)) continue; // rough upper bound: Enterprise+Site at most 32+32; add dynamic segments + 4 slashes var len = 32 + 32 + area.Name.Length + line.Name.Length + eq.Name.Length + 4; if (len > MaxPathLength) errors.Add(new("PathTooLong", $"Equipment path exceeds {MaxPathLength} chars (approx {len})", eq.EquipmentId)); } } private static void ValidateEquipmentUuidImmutability(DraftSnapshot draft, List errors) { var priorById = draft.PriorEquipment .GroupBy(e => e.EquipmentId) .ToDictionary(g => g.Key, g => g.First().EquipmentUuid); foreach (var eq in draft.Equipment) { if (priorById.TryGetValue(eq.EquipmentId, out var priorUuid) && priorUuid != eq.EquipmentUuid) errors.Add(new("EquipmentUuidImmutable", $"EquipmentId '{eq.EquipmentId}' had UUID '{priorUuid}' in a prior generation; cannot change to '{eq.EquipmentUuid}'", eq.EquipmentId)); } } private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List errors) { var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId); foreach (var di in draft.DriverInstances) { if (!nsById.TryGetValue(di.NamespaceId, out var ns)) { errors.Add(new("NamespaceUnresolved", $"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'", di.DriverInstanceId)); continue; } if (ns.ClusterId != di.ClusterId) errors.Add(new("BadCrossClusterNamespaceBinding", $"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'", di.DriverInstanceId)); } } private static void ValidateReservationPreflight(DraftSnapshot draft, List errors) { var activeByKindValue = draft.ActiveReservations .ToDictionary(r => (r.Kind, r.Value), r => r.EquipmentUuid); foreach (var eq in draft.Equipment) { if (eq.ZTag is not null && activeByKindValue.TryGetValue((ReservationKind.ZTag, eq.ZTag), out var ztagOwner) && ztagOwner != eq.EquipmentUuid) errors.Add(new("BadDuplicateExternalIdentifier", $"ZTag '{eq.ZTag}' is already reserved by EquipmentUuid '{ztagOwner}'", eq.EquipmentId)); if (eq.SAPID is not null && activeByKindValue.TryGetValue((ReservationKind.SAPID, eq.SAPID), out var sapOwner) && sapOwner != eq.EquipmentUuid) errors.Add(new("BadDuplicateExternalIdentifier", $"SAPID '{eq.SAPID}' is already reserved by EquipmentUuid '{sapOwner}'", eq.EquipmentId)); } } /// Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID. public static string DeriveEquipmentId(Guid uuid) => "EQ-" + uuid.ToString("N")[..12].ToLowerInvariant(); private static void ValidateEquipmentIdDerivation(DraftSnapshot draft, List errors) { foreach (var eq in draft.Equipment) { var expected = DeriveEquipmentId(eq.EquipmentUuid); if (!string.Equals(eq.EquipmentId, expected, StringComparison.Ordinal)) errors.Add(new("EquipmentIdNotDerived", $"Equipment.EquipmentId '{eq.EquipmentId}' does not match the canonical derivation '{expected}'", eq.EquipmentId)); } } private static void ValidateDriverNamespaceCompatibility(DraftSnapshot draft, List errors) { var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId); foreach (var di in draft.DriverInstances) { if (!nsById.TryGetValue(di.NamespaceId, out var ns)) continue; var compat = ns.Kind switch { NamespaceKind.SystemPlatform => di.DriverType == "Galaxy", NamespaceKind.Equipment => di.DriverType != "Galaxy", _ => true, }; if (!compat) errors.Add(new("DriverNamespaceKindMismatch", $"DriverInstance '{di.DriverInstanceId}' ({di.DriverType}) is not allowed in {ns.Kind} namespace", di.DriverInstanceId)); } } }