138 lines
5.8 KiB
C#
138 lines
5.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
|
/// Same content always produces the same hash across runs.
|
|
/// <para>
|
|
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR
|
|
/// declaration order, which it does NOT sort. Stable output therefore relies
|
|
/// on the private <c>Hashable*</c> 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 <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
|
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
|
/// </para>
|
|
/// </summary>
|
|
public class RevisionHashService
|
|
{
|
|
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
WriteIndented = false
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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. 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
|
|
{
|
|
public List<HashableAlarm> Alarms { get; init; } = [];
|
|
public int? AreaId { get; init; }
|
|
public List<HashableAttribute> Attributes { get; init; } = [];
|
|
public string InstanceUniqueName { get; init; } = string.Empty;
|
|
public List<HashableScript> 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; }
|
|
}
|
|
}
|