faef2d0de6
- WP-23: ITemplateEngineRepository full EF Core implementation - WP-1: Template CRUD with deletion constraints (instances, children, compositions) - WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity - WP-5: Shared script CRUD with syntax validation - WP-6–7: Composition with recursive nesting and canonical naming - WP-8–11: Override granularity, locking rules, inheritance/composition scope - WP-12: Naming collision detection on canonical names (recursive) - WP-13: Graph acyclicity (inheritance + composition cycles) Core services: TemplateService, SharedScriptService, TemplateResolver, LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
142 lines
5.8 KiB
C#
142 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 by using
|
|
/// canonical JSON serialization with sorted keys.
|
|
/// </summary>
|
|
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() }
|
|
};
|
|
|
|
/// <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 (sorted property names via camelCase + alphabetical)
|
|
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; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A JSON converter factory that ensures properties are serialized in alphabetical order
|
|
/// for deterministic output. Works with record types.
|
|
/// </summary>
|
|
internal class SortedPropertiesConverterFactory : JsonConverterFactory
|
|
{
|
|
public override bool CanConvert(Type typeToConvert) => false;
|
|
|
|
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
|
|
}
|