402 lines
18 KiB
C#
402 lines
18 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
|
|
/// <summary>
|
|
/// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set
|
|
/// carried by a <see cref="DeployArtifactsCommand"/> (shared scripts, external
|
|
/// systems, database connections, and the site's data connections). The hash is
|
|
/// stamped on <c>Site.ArtifactRevisionHash</c> after a successful artifact deploy
|
|
/// and compared against a freshly-computed hash of the current definitions to tell
|
|
/// whether a site's deployed artifacts are out of date.
|
|
/// <para>
|
|
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration
|
|
/// order and does NOT sort them. Stable output therefore relies on the private
|
|
/// <c>Hashable*</c> records below declaring their properties in alphabetical
|
|
/// (ordinal) order — a contributor adding a property out of order would silently
|
|
/// change every site's artifact hash and flag the whole fleet out of date. The
|
|
/// ordering is guarded by
|
|
/// <c>ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>,
|
|
/// mirroring <c>RevisionHashService</c>. Every artifact list is explicitly sorted by
|
|
/// <c>Name</c> (ordinal) before hashing, so repository row order never leaks into the
|
|
/// hash, and an external system's <c>MethodDefinitionsJson</c> — itself a serialized
|
|
/// collection — is re-canonicalised with its methods sorted by name.
|
|
/// </para>
|
|
/// <para>
|
|
/// EXCLUDED FROM THE HASH: the command's <c>DeploymentId</c> and <c>Timestamp</c>
|
|
/// (both volatile per deploy — two deploys of identical definitions must hash the
|
|
/// same), and <c>NotificationLists</c>/<c>SmtpConfigurations</c> (notification
|
|
/// delivery is central-only; central never populates those fields, so folding them
|
|
/// in would only add a permanently-null branch).
|
|
/// </para>
|
|
/// <para>
|
|
/// INCLUDED: the command's <c>Secrets</c> — projected as a content digest (SHA-256 of
|
|
/// ciphertext + nonce + tag + wrapped-DEK + wrap-nonce + wrap-tag) alongside the name,
|
|
/// revision, and KekId, so plaintext never enters the hash input and re-wrapping under
|
|
/// a new KEK with unchanged content still moves the hash. <c>MissingSecrets</c> —
|
|
/// central-only names of secret references that failed to resolve — is folded in as
|
|
/// distinguishable marker entries (<c>Missing = true</c>, no digest) so a site stays
|
|
/// flagged out of date while a reference is broken, and a resolved secret hashes
|
|
/// differently from the same name unresolved.
|
|
/// </para>
|
|
/// <para>
|
|
/// A no-op re-save of a data connection does NOT change the hash. Connection
|
|
/// configuration is encrypted at rest through an EF value converter, so the
|
|
/// ciphertext changes on every save, but the entity — and hence
|
|
/// <see cref="DataConnectionArtifact"/> and this hash — only ever sees the
|
|
/// plaintext. Re-encryption at rest is therefore invisible here, which is exactly
|
|
/// what keeps the "out of date" indicator quiet for a save that changed nothing.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ArtifactRevisionHashService
|
|
{
|
|
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
WriteIndented = false
|
|
};
|
|
|
|
/// <summary>
|
|
/// Computes the deterministic SHA-256 hash of the artifact set carried by the given
|
|
/// per-site deploy command. Identical artifact definitions always produce an identical
|
|
/// hash, regardless of deployment id, timestamp, or the order the artifacts were
|
|
/// returned by their repositories.
|
|
/// </summary>
|
|
/// <param name="command">The per-site artifact deployment command to hash.</param>
|
|
/// <returns>
|
|
/// The canonical revision hash, encoded as <c>sha256:</c> followed by the lower-case
|
|
/// hex digest (the same encoding as <c>RevisionHashService</c>).
|
|
/// </returns>
|
|
public string ComputeHash(DeployArtifactsCommand command)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(command);
|
|
|
|
// Each list is projected null-when-empty and the serializer omits null
|
|
// properties, so an absent artifact set and an empty one hash identically.
|
|
var hashInput = new HashableArtifactSet
|
|
{
|
|
DataConnections = command.DataConnections is { Count: > 0 }
|
|
? command.DataConnections
|
|
.OrderBy(c => c.Name, StringComparer.Ordinal)
|
|
.Select(c => new HashableDataConnection
|
|
{
|
|
BackupConfigurationJson = c.BackupConfigurationJson,
|
|
FailoverRetryCount = c.FailoverRetryCount,
|
|
Name = c.Name,
|
|
PrimaryConfigurationJson = c.PrimaryConfigurationJson,
|
|
Protocol = c.Protocol
|
|
})
|
|
.ToList()
|
|
: null,
|
|
DatabaseConnections = command.DatabaseConnections is { Count: > 0 }
|
|
? command.DatabaseConnections
|
|
.OrderBy(d => d.Name, StringComparer.Ordinal)
|
|
.Select(d => new HashableDatabaseConnection
|
|
{
|
|
ConnectionString = d.ConnectionString,
|
|
MaxRetries = d.MaxRetries,
|
|
Name = d.Name,
|
|
RetryDelayTicks = d.RetryDelay.Ticks
|
|
})
|
|
.ToList()
|
|
: null,
|
|
ExternalSystems = command.ExternalSystems is { Count: > 0 }
|
|
? command.ExternalSystems
|
|
.OrderBy(e => e.Name, StringComparer.Ordinal)
|
|
.Select(e => new HashableExternalSystem
|
|
{
|
|
AuthConfiguration = e.AuthConfiguration,
|
|
AuthType = e.AuthType,
|
|
EndpointUrl = e.EndpointUrl,
|
|
MaxRetries = e.MaxRetries,
|
|
MethodDefinitionsJson = CanonicalizeMethodDefinitions(e.MethodDefinitionsJson),
|
|
Name = e.Name,
|
|
RetryDelayTicks = e.RetryDelay.Ticks,
|
|
TimeoutSeconds = e.TimeoutSeconds
|
|
})
|
|
.ToList()
|
|
: null,
|
|
Secrets = BuildHashableSecrets(command),
|
|
SharedScripts = command.SharedScripts is { Count: > 0 }
|
|
? command.SharedScripts
|
|
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
|
.Select(s => new HashableSharedScript
|
|
{
|
|
Code = s.Code,
|
|
Name = s.Name,
|
|
ParameterDefinitions = s.ParameterDefinitions,
|
|
ReturnDefinition = s.ReturnDefinition
|
|
})
|
|
.ToList()
|
|
: null
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
|
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
|
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects the command's <c>Secrets</c> and <c>MissingSecrets</c> into a single
|
|
/// name-sorted list of <see cref="HashableSecret"/> entries. A present secret carries a
|
|
/// content digest over its ciphertext material (never the plaintext, which this service
|
|
/// never sees) plus its <c>KekId</c> and <c>Revision</c>; a missing reference carries only
|
|
/// its name and <c>Missing = true</c>, so the same name present vs. missing always hashes
|
|
/// differently. Returns null when there is nothing to hash, matching every other
|
|
/// null-when-empty artifact list here.
|
|
/// </summary>
|
|
private static List<HashableSecret>? BuildHashableSecrets(DeployArtifactsCommand command)
|
|
{
|
|
var list = new List<HashableSecret>();
|
|
|
|
foreach (var s in command.Secrets ?? [])
|
|
{
|
|
list.Add(new HashableSecret
|
|
{
|
|
ContentDigest = Convert.ToHexStringLower(SHA256.HashData(
|
|
[.. s.Ciphertext, .. s.Nonce, .. s.Tag, .. s.WrappedDek, .. s.WrapNonce, .. s.WrapTag])),
|
|
KekId = s.KekId,
|
|
Missing = null,
|
|
Name = s.Name,
|
|
Revision = s.Revision
|
|
});
|
|
}
|
|
|
|
foreach (var m in command.MissingSecrets ?? [])
|
|
{
|
|
list.Add(new HashableSecret { Missing = true, Name = m });
|
|
}
|
|
|
|
return list.Count == 0
|
|
? null
|
|
: list.OrderBy(x => x.Name, StringComparer.Ordinal).ThenBy(x => x.Missing == true).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-serializes an external system's JSON-encoded method list with its methods sorted
|
|
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
|
|
/// that is not a parseable JSON array is returned verbatim — the hash still changes when
|
|
/// the payload does, which is the only guarantee this method owes.
|
|
/// <para>
|
|
/// The name property is matched CASE-INSENSITIVELY. Today's producer
|
|
/// (<c>ArtifactDeploymentService.FetchGlobalArtifactsAsync</c>) serializes an anonymous
|
|
/// type, so the key is PascalCase <c>Name</c> — but a future camelCase naming policy there
|
|
/// would otherwise silently turn this sort into a no-op and let row order move the hash
|
|
/// again, with nothing failing.
|
|
/// </para>
|
|
/// </summary>
|
|
private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(methodDefinitionsJson))
|
|
return methodDefinitionsJson;
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(methodDefinitionsJson);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
|
return methodDefinitionsJson;
|
|
|
|
var ordered = document.RootElement
|
|
.EnumerateArray()
|
|
.Select(element => element.Clone())
|
|
.OrderBy(MethodSortKey, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
return JsonSerializer.Serialize(ordered, CanonicalJsonOptions);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return methodDefinitionsJson;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sort key for one serialized external-system method: its name property, matched
|
|
/// case-insensitively so the key survives a naming-policy change at the producer.
|
|
/// Elements that are not objects, or carry no string name, sort first under an empty key —
|
|
/// their relative order is then whatever the payload gave, which is the best a nameless
|
|
/// element allows.
|
|
/// </summary>
|
|
private static string MethodSortKey(JsonElement element)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
return string.Empty;
|
|
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, "Name", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return property.Value.ValueKind == JsonValueKind.String
|
|
? property.Value.GetString() ?? string.Empty
|
|
: string.Empty;
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
// Internal types for deterministic serialization. Properties MUST be declared in
|
|
// alphabetical (ordinal) 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 HashableArtifactSet
|
|
{
|
|
/// <summary>
|
|
/// The site's data connections, sorted by name; null when the site has none.
|
|
/// </summary>
|
|
public List<HashableDataConnection>? DataConnections { get; init; }
|
|
/// <summary>
|
|
/// The system-wide database connections, sorted by name; null when there are none.
|
|
/// </summary>
|
|
public List<HashableDatabaseConnection>? DatabaseConnections { get; init; }
|
|
/// <summary>
|
|
/// The system-wide external systems, sorted by name; null when there are none.
|
|
/// </summary>
|
|
public List<HashableExternalSystem>? ExternalSystems { get; init; }
|
|
/// <summary>
|
|
/// The secrets referenced by this site (present, as a content digest, or missing, as
|
|
/// a marker entry), sorted by name then by missing-last; null when there are none.
|
|
/// </summary>
|
|
public List<HashableSecret>? Secrets { get; init; }
|
|
/// <summary>
|
|
/// The system-wide shared scripts, sorted by name; null when there are none.
|
|
/// </summary>
|
|
public List<HashableSharedScript>? SharedScripts { get; init; }
|
|
}
|
|
|
|
private sealed record HashableDataConnection
|
|
{
|
|
/// <summary>
|
|
/// Backup connection configuration JSON (plaintext in memory), if any.
|
|
/// </summary>
|
|
public string? BackupConfigurationJson { get; init; }
|
|
/// <summary>
|
|
/// Number of failover retries before giving up.
|
|
/// </summary>
|
|
public int FailoverRetryCount { get; init; }
|
|
/// <summary>
|
|
/// The connection name — the key scripts and bindings address it by.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Primary connection configuration JSON (plaintext in memory), if any.
|
|
/// </summary>
|
|
public string? PrimaryConfigurationJson { get; init; }
|
|
/// <summary>
|
|
/// Protocol name (e.g. "OpcUa").
|
|
/// </summary>
|
|
public string Protocol { get; init; } = string.Empty;
|
|
}
|
|
|
|
private sealed record HashableDatabaseConnection
|
|
{
|
|
/// <summary>
|
|
/// The database connection string shipped to the site.
|
|
/// </summary>
|
|
public string ConnectionString { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Store-and-Forward retry budget for this connection's cached writes (0 = unset).
|
|
/// </summary>
|
|
public int MaxRetries { get; init; }
|
|
/// <summary>
|
|
/// The database connection name.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
|
|
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
|
|
/// </summary>
|
|
public long RetryDelayTicks { get; init; }
|
|
}
|
|
|
|
private sealed record HashableExternalSystem
|
|
{
|
|
/// <summary>
|
|
/// JSON-serialized authentication configuration for <see cref="AuthType"/>.
|
|
/// </summary>
|
|
public string? AuthConfiguration { get; init; }
|
|
/// <summary>
|
|
/// Authentication type identifier (e.g. <c>ApiKey</c>, <c>BasicAuth</c>).
|
|
/// </summary>
|
|
public string AuthType { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Base URL of the system's HTTP endpoint.
|
|
/// </summary>
|
|
public string EndpointUrl { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Store-and-Forward retry budget for this system's cached calls (0 = unset).
|
|
/// </summary>
|
|
public int MaxRetries { get; init; }
|
|
/// <summary>
|
|
/// The system's method list, re-canonicalised with the methods sorted by name;
|
|
/// null when the system defines none.
|
|
/// </summary>
|
|
public string? MethodDefinitionsJson { get; init; }
|
|
/// <summary>
|
|
/// Unique system name — the key scripts call it by.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
|
|
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
|
|
/// </summary>
|
|
public long RetryDelayTicks { get; init; }
|
|
/// <summary>
|
|
/// Per-system HTTP round-trip timeout in seconds (0 = unset).
|
|
/// </summary>
|
|
public int TimeoutSeconds { get; init; }
|
|
}
|
|
|
|
private sealed record HashableSecret
|
|
{
|
|
/// <summary>
|
|
/// SHA-256 digest over the secret's ciphertext, nonce, tag, wrapped DEK, wrap-nonce,
|
|
/// and wrap-tag — never the plaintext. Null for a <see cref="Missing"/> marker entry.
|
|
/// </summary>
|
|
public string? ContentDigest { get; init; }
|
|
/// <summary>
|
|
/// The key-encryption-key identifier the secret is wrapped under. Null for a
|
|
/// <see cref="Missing"/> marker entry.
|
|
/// </summary>
|
|
public string? KekId { get; init; }
|
|
/// <summary>
|
|
/// True when this entry is a <c>MissingSecrets</c> marker (a referenced secret that
|
|
/// failed to resolve at central) rather than a resolved secret; null when resolved,
|
|
/// so the same name present vs. missing always serializes — and hashes — differently.
|
|
/// </summary>
|
|
public bool? Missing { get; init; }
|
|
/// <summary>
|
|
/// The secret's name — the key data connection configuration references it by.
|
|
/// </summary>
|
|
public required string Name { get; init; }
|
|
/// <summary>
|
|
/// The secret's revision number. Null for a <see cref="Missing"/> marker entry.
|
|
/// </summary>
|
|
public long? Revision { get; init; }
|
|
}
|
|
|
|
private sealed record HashableSharedScript
|
|
{
|
|
/// <summary>
|
|
/// The shared script's source code.
|
|
/// </summary>
|
|
public string Code { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// The shared script name — the key other scripts call it by.
|
|
/// </summary>
|
|
public string Name { get; init; } = string.Empty;
|
|
/// <summary>
|
|
/// JSON representation of the script's parameter definitions.
|
|
/// </summary>
|
|
public string? ParameterDefinitions { get; init; }
|
|
/// <summary>
|
|
/// JSON representation of the script's return type definition.
|
|
/// </summary>
|
|
public string? ReturnDefinition { get; init; }
|
|
}
|
|
}
|