< Summary

Information
Class: AsiBackbone.Core.Integrity.AuditIntegrityLink
Assembly: AsiBackbone.Core
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Integrity/AuditIntegrityLink.cs
Line coverage
100%
Covered lines: 130
Uncovered lines: 0
Coverable lines: 130
Total lines: 284
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%44100%
get_ChainId()100%11100%
get_Sequence()100%11100%
get_RecordId()100%11100%
get_RecordType()100%11100%
get_RecordHash()100%11100%
get_PreviousLinkHash()100%11100%
get_LinkHash()100%11100%
get_HashAlgorithm()100%11100%
get_CanonicalizationVersion()100%11100%
get_SchemaVersion()100%11100%
get_CreatedUtc()100%11100%
get_Metadata()100%11100%
get_IsGenesis()100%22100%
CreateGenesis(...)100%11100%
Append(...)100%11100%
Rehydrate(...)100%11100%
ComputeExpectedLinkHash()100%11100%
BuildCanonicalPayload()100%11100%
CreateNext(...)100%11100%
NormalizeMetadata(...)100%1414100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Core/Integrity/AuditIntegrityLink.cs

#LineLine coverage
 1using System.Collections.ObjectModel;
 2using AsiBackbone.Core.Serialization;
 3using AsiBackbone.Core.Signing;
 4
 5namespace AsiBackbone.Core.Integrity;
 6
 7/// <summary>
 8/// Represents one append-only hash-chain link for a canonical audit or outbox record hash.
 9/// </summary>
 10/// <remarks>
 11/// A link proves only local sequence continuity when later verified. It does not make storage immutable,
 12/// externally anchored, or legally non-repudiable by itself.
 13/// </remarks>
 14public sealed class AuditIntegrityLink
 15{
 216    private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
 217        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 18
 15419    private AuditIntegrityLink(
 15420        string chainId,
 15421        long sequence,
 15422        string recordId,
 15423        string recordType,
 15424        string recordHash,
 15425        string previousLinkHash,
 15426        string linkHash,
 15427        string hashAlgorithm,
 15428        string canonicalizationVersion,
 15429        string schemaVersion,
 15430        DateTimeOffset createdUtc,
 15431        IReadOnlyDictionary<string, string> metadata)
 32    {
 15433        ArgumentException.ThrowIfNullOrWhiteSpace(chainId);
 15434        ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
 15435        ArgumentException.ThrowIfNullOrWhiteSpace(recordType);
 15436        ArgumentException.ThrowIfNullOrWhiteSpace(recordHash);
 15437        ArgumentException.ThrowIfNullOrWhiteSpace(linkHash);
 15438        ArgumentException.ThrowIfNullOrWhiteSpace(hashAlgorithm);
 15439        ArgumentException.ThrowIfNullOrWhiteSpace(canonicalizationVersion);
 15440        ArgumentException.ThrowIfNullOrWhiteSpace(schemaVersion);
 41
 15442        if (sequence < 1)
 43        {
 244            throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Chain sequence must be greater than zero.
 45        }
 46
 15247        ChainId = chainId.Trim();
 15248        Sequence = sequence;
 15249        RecordId = recordId.Trim();
 15250        RecordType = recordType.Trim();
 15251        RecordHash = recordHash.Trim().ToLowerInvariant();
 15252        PreviousLinkHash = string.IsNullOrWhiteSpace(previousLinkHash) ? string.Empty : previousLinkHash.Trim().ToLowerI
 15253        LinkHash = linkHash.Trim().ToLowerInvariant();
 15254        HashAlgorithm = CanonicalPayloadHash.NormalizeHashAlgorithm(hashAlgorithm);
 15255        CanonicalizationVersion = canonicalizationVersion.Trim();
 15256        SchemaVersion = AsiBackboneSchemaVersions.Normalize(schemaVersion);
 15257        CreatedUtc = createdUtc.ToUniversalTime();
 15258        Metadata = metadata;
 15259    }
 60
 61    /// <summary>
 62    /// Gets the logical chain identifier.
 63    /// </summary>
 43264    public string ChainId { get; }
 65
 66    /// <summary>
 67    /// Gets the one-based sequence number within the chain.
 68    /// </summary>
 61469    public long Sequence { get; }
 70
 71    /// <summary>
 72    /// Gets the record identifier bound into this link.
 73    /// </summary>
 23674    public string RecordId { get; }
 75
 76    /// <summary>
 77    /// Gets the canonical record artifact type bound into this link.
 78    /// </summary>
 18879    public string RecordType { get; }
 80
 81    /// <summary>
 82    /// Gets the canonical record hash bound into this link.
 83    /// </summary>
 19084    public string RecordHash { get; }
 85
 86    /// <summary>
 87    /// Gets the previous link hash. Genesis links use an empty value.
 88    /// </summary>
 26489    public string PreviousLinkHash { get; }
 90
 91    /// <summary>
 92    /// Gets this link's canonical hash.
 93    /// </summary>
 13894    public string LinkHash { get; }
 95
 96    /// <summary>
 97    /// Gets the hash algorithm used for record and link hashes.
 98    /// </summary>
 24899    public string HashAlgorithm { get; }
 100
 101    /// <summary>
 102    /// Gets the canonicalization version used for link hashing.
 103    /// </summary>
 188104    public string CanonicalizationVersion { get; }
 105
 106    /// <summary>
 107    /// Gets the schema version for the integrity link.
 108    /// </summary>
 188109    public string SchemaVersion { get; }
 110
 111    /// <summary>
 112    /// Gets the UTC timestamp when this link was created.
 113    /// </summary>
 190114    public DateTimeOffset CreatedUtc { get; }
 115
 116    /// <summary>
 117    /// Gets provider-neutral metadata associated with the link.
 118    /// </summary>
 66119    public IReadOnlyDictionary<string, string> Metadata { get; }
 120
 121    /// <summary>
 122    /// Gets a value indicating whether this is the first link in the chain.
 123    /// </summary>
 8124    public bool IsGenesis => Sequence == 1 && PreviousLinkHash.Length == 0;
 125
 126    /// <summary>
 127    /// Creates the first link in a chain.
 128    /// </summary>
 129    public static AuditIntegrityLink CreateGenesis(
 130        string chainId,
 131        CanonicalPayloadHash recordHash,
 132        DateTimeOffset createdUtc,
 133        IReadOnlyDictionary<string, string>? metadata = null)
 134    {
 34135        return CreateNext(chainId, 1, recordHash, string.Empty, createdUtc, metadata);
 136    }
 137
 138    /// <summary>
 139    /// Appends a link after the supplied previous link.
 140    /// </summary>
 141    public static AuditIntegrityLink Append(
 142        AuditIntegrityLink previousLink,
 143        CanonicalPayloadHash recordHash,
 144        DateTimeOffset createdUtc,
 145        IReadOnlyDictionary<string, string>? metadata = null)
 146    {
 24147        ArgumentNullException.ThrowIfNull(previousLink);
 148
 22149        return CreateNext(
 22150            previousLink.ChainId,
 22151            previousLink.Sequence + 1,
 22152            recordHash,
 22153            previousLink.LinkHash,
 22154            createdUtc,
 22155            metadata);
 156    }
 157
 158    /// <summary>
 159    /// Rehydrates a persisted link without recomputing its link hash.
 160    /// </summary>
 161    public static AuditIntegrityLink Rehydrate(
 162        string chainId,
 163        long sequence,
 164        string recordId,
 165        string recordType,
 166        string recordHash,
 167        string previousLinkHash,
 168        string linkHash,
 169        string hashAlgorithm,
 170        string canonicalizationVersion,
 171        string schemaVersion,
 172        DateTimeOffset createdUtc,
 173        IReadOnlyDictionary<string, string>? metadata = null)
 174    {
 46175        return new AuditIntegrityLink(
 46176            chainId,
 46177            sequence,
 46178            recordId,
 46179            recordType,
 46180            recordHash,
 46181            previousLinkHash,
 46182            linkHash,
 46183            hashAlgorithm,
 46184            canonicalizationVersion,
 46185            schemaVersion,
 46186            createdUtc,
 46187            NormalizeMetadata(metadata));
 188    }
 189
 190    /// <summary>
 191    /// Recomputes the link hash from the persisted link fields.
 192    /// </summary>
 193    public string ComputeExpectedLinkHash()
 194    {
 52195        return CanonicalPayloadHasher.ComputeHash(BuildCanonicalPayload()).HashValue;
 196    }
 197
 198    internal CanonicalPayload BuildCanonicalPayload()
 199    {
 106200        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 106201        {
 106202            ["chainId"] = ChainId,
 106203            ["createdUtc"] = CreatedUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", System.Globalization.CultureInfo.In
 106204            ["hashAlgorithm"] = HashAlgorithm,
 106205            ["previousLinkHash"] = PreviousLinkHash,
 106206            ["recordHash"] = RecordHash,
 106207            ["recordId"] = RecordId,
 106208            ["recordType"] = RecordType,
 106209            ["sequence"] = Sequence
 106210        };
 211
 106212        return CanonicalPayload.Create(
 106213            CanonicalArtifactTypes.AuditIntegrityLink,
 106214            $"{ChainId}:{Sequence}",
 106215            SchemaVersion,
 106216            CanonicalizationVersion,
 106217            content);
 218    }
 219
 220    private static AuditIntegrityLink CreateNext(
 221        string chainId,
 222        long sequence,
 223        CanonicalPayloadHash recordHash,
 224        string previousLinkHash,
 225        DateTimeOffset createdUtc,
 226        IReadOnlyDictionary<string, string>? metadata)
 227    {
 56228        ArgumentNullException.ThrowIfNull(recordHash);
 229
 54230        string schemaVersion = AsiBackboneSchemaVersions.StableArtifactsV1;
 54231        string canonicalizationVersion = recordHash.CanonicalizationVersion;
 54232        var draft = new AuditIntegrityLink(
 54233            chainId,
 54234            sequence,
 54235            recordHash.ArtifactId,
 54236            recordHash.ArtifactType,
 54237            recordHash.HashValue,
 54238            previousLinkHash,
 54239            recordHash.HashValue,
 54240            recordHash.HashAlgorithm,
 54241            canonicalizationVersion,
 54242            schemaVersion,
 54243            createdUtc,
 54244            NormalizeMetadata(metadata));
 245
 54246        string linkHash = CanonicalPayloadHasher.ComputeHash(draft.BuildCanonicalPayload()).HashValue;
 247
 54248        return new AuditIntegrityLink(
 54249            draft.ChainId,
 54250            draft.Sequence,
 54251            draft.RecordId,
 54252            draft.RecordType,
 54253            draft.RecordHash,
 54254            draft.PreviousLinkHash,
 54255            linkHash,
 54256            draft.HashAlgorithm,
 54257            draft.CanonicalizationVersion,
 54258            draft.SchemaVersion,
 54259            draft.CreatedUtc,
 54260            draft.Metadata);
 261    }
 262
 263    private static IReadOnlyDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
 264    {
 100265        if (metadata is null || metadata.Count == 0)
 266        {
 94267            return EmptyMetadata;
 268        }
 269
 6270        Dictionary<string, string> normalized = new(StringComparer.Ordinal);
 271
 32272        foreach (KeyValuePair<string, string> item in metadata)
 273        {
 10274            if (!string.IsNullOrWhiteSpace(item.Key))
 275            {
 6276                normalized[item.Key.Trim()] = item.Value?.Trim() ?? string.Empty;
 277            }
 278        }
 279
 6280        return normalized.Count == 0
 6281            ? EmptyMetadata
 6282            : new ReadOnlyDictionary<string, string>(normalized);
 283    }
 284}