< 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: 118
Uncovered lines: 0
Coverable lines: 118
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_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{
 116    private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
 117        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 18
 10719    private AuditIntegrityLink(
 10720        string chainId,
 10721        long sequence,
 10722        string recordId,
 10723        string recordType,
 10724        string recordHash,
 10725        string previousLinkHash,
 10726        string linkHash,
 10727        string hashAlgorithm,
 10728        string canonicalizationVersion,
 10729        string schemaVersion,
 10730        DateTimeOffset createdUtc,
 10731        IReadOnlyDictionary<string, string> metadata)
 32    {
 10733        ArgumentException.ThrowIfNullOrWhiteSpace(chainId);
 10734        ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
 10735        ArgumentException.ThrowIfNullOrWhiteSpace(recordType);
 10736        ArgumentException.ThrowIfNullOrWhiteSpace(recordHash);
 10737        ArgumentException.ThrowIfNullOrWhiteSpace(linkHash);
 10738        ArgumentException.ThrowIfNullOrWhiteSpace(hashAlgorithm);
 10739        ArgumentException.ThrowIfNullOrWhiteSpace(canonicalizationVersion);
 10740        ArgumentException.ThrowIfNullOrWhiteSpace(schemaVersion);
 41
 10742        if (sequence < 1)
 43        {
 144            throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Chain sequence must be greater than zero.
 45        }
 46
 10647        ChainId = chainId.Trim();
 10648        Sequence = sequence;
 10649        RecordId = recordId.Trim();
 10650        RecordType = recordType.Trim();
 10651        RecordHash = recordHash.Trim().ToLowerInvariant();
 10652        PreviousLinkHash = string.IsNullOrWhiteSpace(previousLinkHash) ? string.Empty : previousLinkHash.Trim().ToLowerI
 10653        LinkHash = linkHash.Trim().ToLowerInvariant();
 10654        HashAlgorithm = CanonicalPayloadHash.NormalizeHashAlgorithm(hashAlgorithm);
 10655        CanonicalizationVersion = canonicalizationVersion.Trim();
 10656        SchemaVersion = GovernanceSchemaVersions.Normalize(schemaVersion);
 10657        CreatedUtc = createdUtc.ToUniversalTime();
 10658        Metadata = metadata;
 10659    }
 60
 61    /// <summary>
 62    /// Gets the logical chain identifier.
 63    /// </summary>
 64    public string ChainId { get; }
 65
 66    /// <summary>
 67    /// Gets the one-based sequence number within the chain.
 68    /// </summary>
 69    public long Sequence { get; }
 70
 71    /// <summary>
 72    /// Gets the record identifier bound into this link.
 73    /// </summary>
 74    public string RecordId { get; }
 75
 76    /// <summary>
 77    /// Gets the canonical record artifact type bound into this link.
 78    /// </summary>
 79    public string RecordType { get; }
 80
 81    /// <summary>
 82    /// Gets the canonical record hash bound into this link.
 83    /// </summary>
 84    public string RecordHash { get; }
 85
 86    /// <summary>
 87    /// Gets the previous link hash. Genesis links use an empty value.
 88    /// </summary>
 89    public string PreviousLinkHash { get; }
 90
 91    /// <summary>
 92    /// Gets this link's canonical hash.
 93    /// </summary>
 94    public string LinkHash { get; }
 95
 96    /// <summary>
 97    /// Gets the hash algorithm used for record and link hashes.
 98    /// </summary>
 99    public string HashAlgorithm { get; }
 100
 101    /// <summary>
 102    /// Gets the canonicalization version used for link hashing.
 103    /// </summary>
 104    public string CanonicalizationVersion { get; }
 105
 106    /// <summary>
 107    /// Gets the schema version for the integrity link.
 108    /// </summary>
 109    public string SchemaVersion { get; }
 110
 111    /// <summary>
 112    /// Gets the UTC timestamp when this link was created.
 113    /// </summary>
 114    public DateTimeOffset CreatedUtc { get; }
 115
 116    /// <summary>
 117    /// Gets provider-neutral metadata associated with the link.
 118    /// </summary>
 119    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>
 4124    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    {
 23135        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    {
 23147        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    {
 19175        return new AuditIntegrityLink(
 19176            chainId,
 19177            sequence,
 19178            recordId,
 19179            recordType,
 19180            recordHash,
 19181            previousLinkHash,
 19182            linkHash,
 19183            hashAlgorithm,
 19184            canonicalizationVersion,
 19185            schemaVersion,
 19186            createdUtc,
 19187            NormalizeMetadata(metadata));
 188    }
 189
 190    /// <summary>
 191    /// Recomputes the link hash from the persisted link fields.
 192    /// </summary>
 193    public string ComputeExpectedLinkHash()
 194    {
 31195        return CanonicalPayloadHasher.ComputeHash(BuildCanonicalPayload()).HashValue;
 196    }
 197
 198    internal CanonicalPayload BuildCanonicalPayload()
 199    {
 75200        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 75201        {
 75202            ["chainId"] = ChainId,
 75203            ["createdUtc"] = CreatedUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", System.Globalization.CultureInfo.In
 75204            ["hashAlgorithm"] = HashAlgorithm,
 75205            ["previousLinkHash"] = PreviousLinkHash,
 75206            ["recordHash"] = RecordHash,
 75207            ["recordId"] = RecordId,
 75208            ["recordType"] = RecordType,
 75209            ["sequence"] = Sequence
 75210        };
 211
 75212        return CanonicalPayload.Create(
 75213            CanonicalArtifactTypes.AuditIntegrityLink,
 75214            $"{ChainId}:{Sequence}",
 75215            SchemaVersion,
 75216            CanonicalizationVersion,
 75217            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    {
 45228        ArgumentNullException.ThrowIfNull(recordHash);
 229
 44230        string schemaVersion = GovernanceSchemaVersions.StableArtifactsV1;
 44231        string canonicalizationVersion = recordHash.CanonicalizationVersion;
 44232        var draft = new AuditIntegrityLink(
 44233            chainId,
 44234            sequence,
 44235            recordHash.ArtifactId,
 44236            recordHash.ArtifactType,
 44237            recordHash.HashValue,
 44238            previousLinkHash,
 44239            recordHash.HashValue,
 44240            recordHash.HashAlgorithm,
 44241            canonicalizationVersion,
 44242            schemaVersion,
 44243            createdUtc,
 44244            NormalizeMetadata(metadata));
 245
 44246        string linkHash = CanonicalPayloadHasher.ComputeHash(draft.BuildCanonicalPayload()).HashValue;
 247
 44248        return new AuditIntegrityLink(
 44249            draft.ChainId,
 44250            draft.Sequence,
 44251            draft.RecordId,
 44252            draft.RecordType,
 44253            draft.RecordHash,
 44254            draft.PreviousLinkHash,
 44255            linkHash,
 44256            draft.HashAlgorithm,
 44257            draft.CanonicalizationVersion,
 44258            draft.SchemaVersion,
 44259            draft.CreatedUtc,
 44260            draft.Metadata);
 261    }
 262
 263    private static IReadOnlyDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
 264    {
 63265        if (metadata is null || metadata.Count == 0)
 266        {
 60267            return EmptyMetadata;
 268        }
 269
 3270        Dictionary<string, string> normalized = new(StringComparer.Ordinal);
 271
 16272        foreach (KeyValuePair<string, string> item in metadata)
 273        {
 5274            if (!string.IsNullOrWhiteSpace(item.Key))
 275            {
 3276                normalized[item.Key.Trim()] = item.Value?.Trim() ?? string.Empty;
 277            }
 278        }
 279
 3280        return normalized.Count == 0
 3281            ? EmptyMetadata
 3282            : new ReadOnlyDictionary<string, string>(normalized);
 283    }
 284}