using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using ScadaLink.Commons.Types.Flattening; namespace ScadaLink.TemplateEngine.Flattening; /// /// Produces a deterministic SHA-256 hash of a FlattenedConfiguration. /// Same content always produces the same hash across runs by using /// canonical JSON serialization with sorted keys. /// public class RevisionHashService { private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { // Sort properties alphabetically for determinism PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false, // Ensure consistent ordering Converters = { new SortedPropertiesConverterFactory() } }; /// /// 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. /// 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, 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, PriorityLevel = a.PriorityLevel, IsLocked = a.IsLocked, 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 }) .ToList() }; var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions); var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); return $"sha256:{Convert.ToHexStringLower(hashBytes)}"; } // Internal types for deterministic serialization (sorted property names via camelCase + alphabetical) private sealed record HashableConfiguration { public List Alarms { get; init; } = []; public int? AreaId { get; init; } public List Attributes { get; init; } = []; public string InstanceUniqueName { get; init; } = string.Empty; public List Scripts { get; init; } = []; public int SiteId { get; init; } public int TemplateId { get; init; } } private sealed record HashableAttribute { public int? BoundDataConnectionId { get; init; } public string CanonicalName { get; init; } = string.Empty; public string? DataSourceReference { get; init; } public string DataType { get; init; } = string.Empty; public bool IsLocked { get; init; } public string? Value { get; init; } } private sealed record HashableAlarm { public string CanonicalName { get; init; } = string.Empty; public bool IsLocked { get; init; } public string? OnTriggerScriptCanonicalName { get; init; } public int PriorityLevel { get; init; } public string? TriggerConfiguration { get; init; } public string TriggerType { get; init; } = string.Empty; } private sealed record HashableScript { public string CanonicalName { get; init; } = string.Empty; public string Code { get; init; } = string.Empty; public bool IsLocked { get; init; } public long? MinTimeBetweenRunsTicks { get; init; } public string? ParameterDefinitions { get; init; } public string? ReturnDefinition { get; init; } public string? TriggerConfiguration { get; init; } public string? TriggerType { get; init; } } } /// /// A JSON converter factory that ensures properties are serialized in alphabetical order /// for deterministic output. Works with record types. /// internal class SortedPropertiesConverterFactory : JsonConverterFactory { public override bool CanConvert(Type typeToConvert) => false; public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null; }