using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; /// /// Produces a deterministic SHA-256 hash of a FlattenedConfiguration. /// Same content always produces the same hash across runs. /// /// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR /// declaration order, which it does NOT sort. Stable output therefore relies /// on the private Hashable* records below declaring their properties /// in alphabetical order. A contributor adding a property out of alphabetical /// order would silently change every revision hash; the ordering is guarded /// by RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically. /// Collections are explicitly sorted by CanonicalName before hashing. /// /// /// Native alarm source bindings /// () now participate in the hash so /// that binding edits flag the instance stale. The hashable property is /// populated NULL-WHEN-EMPTY and the serializer omits null properties /// (), so every /// native-source-free configuration keeps its byte-identical pre-migration /// hash — no global staleness storm. Native-source-bearing instances will /// report stale once after upgrade; redeploy clears it — deliberate. /// /// public class RevisionHashService { private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; /// /// Computes a deterministic SHA-256 hash of the given flattened configuration. /// The hash is computed over a canonical JSON representation with sorted keys, /// excluding volatile fields like GeneratedAtUtc. /// /// The flattened configuration to hash. /// A hex-encoded SHA-256 hash string of the canonical configuration representation. public string ComputeHash(FlattenedConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); // Create a hashable representation that excludes volatile fields var hashInput = new HashableConfiguration { InstanceUniqueName = configuration.InstanceUniqueName, TemplateId = configuration.TemplateId, SiteId = configuration.SiteId, AreaId = configuration.AreaId, Attributes = configuration.Attributes .OrderBy(a => a.CanonicalName, StringComparer.Ordinal) .Select(a => new HashableAttribute { CanonicalName = a.CanonicalName, Value = a.Value, DataType = a.DataType, ElementDataType = a.ElementDataType, Description = a.Description, IsLocked = a.IsLocked, DataSourceReference = a.DataSourceReference, BoundDataConnectionId = a.BoundDataConnectionId }) .ToList(), Alarms = configuration.Alarms .OrderBy(a => a.CanonicalName, StringComparer.Ordinal) .Select(a => new HashableAlarm { CanonicalName = a.CanonicalName, Description = a.Description, PriorityLevel = a.PriorityLevel, IsLocked = a.IsLocked, // true-only (null when false) so existing revision hashes are unchanged. IsMesReportable = a.IsMesReportable ? true : null, TriggerType = a.TriggerType, TriggerConfiguration = a.TriggerConfiguration, OnTriggerScriptCanonicalName = a.OnTriggerScriptCanonicalName }) .ToList(), Scripts = configuration.Scripts .OrderBy(s => s.CanonicalName, StringComparer.Ordinal) .Select(s => new HashableScript { CanonicalName = s.CanonicalName, Code = s.Code, IsLocked = s.IsLocked, TriggerType = s.TriggerType, TriggerConfiguration = s.TriggerConfiguration, ParameterDefinitions = s.ParameterDefinitions, ReturnDefinition = s.ReturnDefinition, MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks, // Include the per-script timeout so a change to it // is detected as a configuration change (staleness/redeploy). ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds }) .ToList(), NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 } ? configuration.NativeAlarmSources .OrderBy(n => n.CanonicalName, StringComparer.Ordinal) .Select(n => new HashableNativeAlarmSource { CanonicalName = n.CanonicalName, ConditionFilter = n.ConditionFilter, ConnectionName = n.ConnectionName, IsLocked = n.IsLocked, // true-only (null when false) so existing revision hashes are unchanged. IsMesReportable = n.IsMesReportable ? true : null, SourceReference = n.SourceReference }) .ToList() : null, Connections = configuration.Connections is { Count: > 0 } ? new SortedDictionary( configuration.Connections.ToDictionary( kvp => kvp.Key, kvp => new HashableConnection { BackupConfigurationJson = kvp.Value.BackupConfigurationJson, ConfigurationJson = kvp.Value.ConfigurationJson, FailoverRetryCount = kvp.Value.FailoverRetryCount, Protocol = kvp.Value.Protocol }), StringComparer.Ordinal) : null }; var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions); var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); return $"sha256:{Convert.ToHexStringLower(hashBytes)}"; } // Internal types for deterministic serialization. Properties MUST be declared // in alphabetical order — System.Text.Json emits them in declaration order and // does not sort. See the DETERMINISM CONTRACT note on the class summary. private sealed record HashableConfiguration { /// /// Collection of alarms in the configuration. /// public List Alarms { get; init; } = []; /// /// The area identifier. /// public int? AreaId { get; init; } /// /// Collection of attributes in the configuration. /// public List Attributes { get; init; } = []; /// /// Data connection configurations keyed by connection name. Sorted by key /// (ordinal) to keep serialization deterministic. Null when the deployment /// package carries no connections. /// public SortedDictionary? Connections { get; init; } /// /// The unique instance name. /// public string InstanceUniqueName { get; init; } = string.Empty; /// /// Collection of native alarm source bindings in the configuration. /// Null when the configuration carries no native alarm sources so that /// native-source-free configs hash identically to before this field was /// added (see the MIGRATION note on the class summary). /// public List? NativeAlarmSources { get; init; } /// /// Collection of scripts in the configuration. /// public List Scripts { get; init; } = []; /// /// The site identifier. /// public int SiteId { get; init; } /// /// The template identifier. /// public int TemplateId { get; init; } } private sealed record HashableAttribute { /// /// The data connection identifier the attribute is bound to. /// public int? BoundDataConnectionId { get; init; } /// /// The canonical name of the attribute. /// public string CanonicalName { get; init; } = string.Empty; /// /// The data source reference for the attribute. /// public string? DataSourceReference { get; init; } /// /// The data type of the attribute. /// public string DataType { get; init; } = string.Empty; /// /// The attribute description (authoring-time documentation that still /// travels with the deployed payload). /// public string? Description { get; init; } /// /// For List attributes: the element scalar type name; null otherwise. /// Folded into the hash so a List element-type change (e.g. /// Int32 → Double) with identical JSON-encoded values is detected as a /// staleness/revision change. Null for scalar attributes — and because /// the serializer ignores null properties /// (), scalar-only /// configurations hash identically to before this field was added. /// public string? ElementDataType { get; init; } /// /// Whether the attribute is locked. /// public bool IsLocked { get; init; } /// /// The attribute value. /// public string? Value { get; init; } } private sealed record HashableAlarm { /// /// The canonical name of the alarm. /// public string CanonicalName { get; init; } = string.Empty; /// /// The alarm description (authoring-time documentation that still /// travels with the deployed payload). /// public string? Description { get; init; } /// /// Whether the alarm is locked. /// public bool IsLocked { get; init; } /// /// MES-reportable flag; null when false (the serializer drops nulls), so /// pre-existing configurations hash identically. /// public bool? IsMesReportable { get; init; } /// /// The canonical name of the script triggered by this alarm. /// public string? OnTriggerScriptCanonicalName { get; init; } /// /// The priority level of the alarm. /// public int PriorityLevel { get; init; } /// /// The trigger configuration for the alarm. /// public string? TriggerConfiguration { get; init; } /// /// The trigger type of the alarm. /// public string TriggerType { get; init; } = string.Empty; } private sealed record HashableConnection { /// /// Backup connection configuration JSON, if any. /// public string? BackupConfigurationJson { get; init; } /// /// Primary connection configuration JSON. /// public string? ConfigurationJson { get; init; } /// /// Number of failover retries before giving up. /// public int FailoverRetryCount { get; init; } /// /// Protocol name (e.g. "OpcUa"). /// public string Protocol { get; init; } = string.Empty; } private sealed record HashableNativeAlarmSource { /// /// The path-qualified canonical name of the native alarm source binding. /// public string CanonicalName { get; init; } = string.Empty; /// /// Optional condition filter; null = mirror all conditions under the source. /// public string? ConditionFilter { get; init; } /// /// The data connection name that owns the alarm feed. /// public string ConnectionName { get; init; } = string.Empty; /// /// Whether the native alarm source binding is locked. /// public bool IsLocked { get; init; } /// /// MES-reportable binding flag; null when false (the serializer drops nulls), so /// pre-existing configurations hash identically. /// public bool? IsMesReportable { get; init; } /// /// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area). /// public string SourceReference { get; init; } = string.Empty; } private sealed record HashableScript { /// /// The canonical name of the script. /// public string CanonicalName { get; init; } = string.Empty; /// /// The script code. /// public string Code { get; init; } = string.Empty; /// /// The per-script execution timeout in seconds (null = global). /// public int? ExecutionTimeoutSeconds { get; init; } /// /// Whether the script is locked. /// public bool IsLocked { get; init; } /// /// The minimum time between runs in ticks. /// public long? MinTimeBetweenRunsTicks { get; init; } /// /// JSON representation of parameter definitions. /// public string? ParameterDefinitions { get; init; } /// /// JSON representation of the return type definition. /// public string? ReturnDefinition { get; init; } /// /// The trigger configuration for the script. /// public string? TriggerConfiguration { get; init; } /// /// The trigger type of the script. /// public string? TriggerType { get; init; } } }