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;
}