< 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{
 116    private static readonly IReadOnlyDictionary<string, string> EmptyMetadata =
 117        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
 18
 4519    private AuditIntegrityLink(
 4520        string chainId,
 4521        long sequence,
 4522        string recordId,
 4523        string recordType,
 4524        string recordHash,
 4525        string previousLinkHash,
 4526        string linkHash,
 4527        string hashAlgorithm,
 4528        string canonicalizationVersion,
 4529        string schemaVersion,
 4530        DateTimeOffset createdUtc,
 4531        IReadOnlyDictionary<string, string> metadata)
 32    {
 4533        ArgumentException.ThrowIfNullOrWhiteSpace(chainId);
 4534        ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
 4535        ArgumentException.ThrowIfNullOrWhiteSpace(recordType);
 4536        ArgumentException.ThrowIfNullOrWhiteSpace(recordHash);
 4537        ArgumentException.ThrowIfNullOrWhiteSpace(linkHash);
 4538        ArgumentException.ThrowIfNullOrWhiteSpace(hashAlgorithm);
 4539        ArgumentException.ThrowIfNullOrWhiteSpace(canonicalizationVersion);
 4540        ArgumentException.ThrowIfNullOrWhiteSpace(schemaVersion);
 41
 4542        if (sequence < 1)
 43        {
 144            throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Chain sequence must be greater than zero.
 45        }
 46
 4447        ChainId = chainId.Trim();
 4448        Sequence = sequence;
 4449        RecordId = recordId.Trim();
 4450        RecordType = recordType.Trim();
 4451        RecordHash = recordHash.Trim().ToLowerInvariant();
 4452        PreviousLinkHash = string.IsNullOrWhiteSpace(previousLinkHash) ? string.Empty : previousLinkHash.Trim().ToLowerI
 4453        LinkHash = linkHash.Trim().ToLowerInvariant();
 4454        HashAlgorithm = CanonicalPayloadHash.NormalizeHashAlgorithm(hashAlgorithm);
 4455        CanonicalizationVersion = canonicalizationVersion.Trim();
 4456        SchemaVersion = AsiBackboneSchemaVersions.Normalize(schemaVersion);
 4457        CreatedUtc = createdUtc.ToUniversalTime();
 4458        Metadata = metadata;
 4459    }
 60
 61    /// <summary>
 62    /// Gets the logical chain identifier.
 63    /// </summary>
 11764    public string ChainId { get; }
 65
 66    /// <summary>
 67    /// Gets the one-based sequence number within the chain.
 68    /// </summary>
 17269    public long Sequence { get; }
 70
 71    /// <summary>
 72    /// Gets the record identifier bound into this link.
 73    /// </summary>
 6674    public string RecordId { get; }
 75
 76    /// <summary>
 77    /// Gets the canonical record artifact type bound into this link.
 78    /// </summary>
 5279    public string RecordType { get; }
 80
 81    /// <summary>
 82    /// Gets the canonical record hash bound into this link.
 83    /// </summary>
 5384    public string RecordHash { get; }
 85
 86    /// <summary>
 87    /// Gets the previous link hash. Genesis links use an empty value.
 88    /// </summary>
 7389    public string PreviousLinkHash { get; }
 90
 91    /// <summary>
 92    /// Gets this link's canonical hash.
 93    /// </summary>
 3794    public string LinkHash { get; }
 95
 96    /// <summary>
 97    /// Gets the hash algorithm used for record and link hashes.
 98    /// </summary>
 6699    public string HashAlgorithm { get; }
 100
 101    /// <summary>
 102    /// Gets the canonicalization version used for link hashing.
 103    /// </summary>
 52104    public string CanonicalizationVersion { get; }
 105
 106    /// <summary>
 107    /// Gets the schema version for the integrity link.
 108    /// </summary>
 52109    public string SchemaVersion { get; }
 110
 111    /// <summary>
 112    /// Gets the UTC timestamp when this link was created.
 113    /// </summary>
 53114    public DateTimeOffset CreatedUtc { get; }
 115
 116    /// <summary>
 117    /// Gets provider-neutral metadata associated with the link.
 118    /// </summary>
 24119    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    {
 12135        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    {
 8147        ArgumentNullException.ThrowIfNull(previousLink);
 148
 7149        return CreateNext(
 7150            previousLink.ChainId,
 7151            previousLink.Sequence + 1,
 7152            recordHash,
 7153            previousLink.LinkHash,
 7154            createdUtc,
 7155            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    {
 9175        return new AuditIntegrityLink(
 9176            chainId,
 9177            sequence,
 9178            recordId,
 9179            recordType,
 9180            recordHash,
 9181            previousLinkHash,
 9182            linkHash,
 9183            hashAlgorithm,
 9184            canonicalizationVersion,
 9185            schemaVersion,
 9186            createdUtc,
 9187            NormalizeMetadata(metadata));
 188    }
 189
 190    /// <summary>
 191    /// Recomputes the link hash from the persisted link fields.
 192    /// </summary>
 193    public string ComputeExpectedLinkHash()
 194    {
 10195        return CanonicalPayloadHasher.ComputeHash(BuildCanonicalPayload()).HashValue;
 196    }
 197
 198    internal CanonicalPayload BuildCanonicalPayload()
 199    {
 28200        SortedDictionary<string, object?> content = new(StringComparer.Ordinal)
 28201        {
 28202            ["chainId"] = ChainId,
 28203            ["createdUtc"] = CreatedUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", System.Globalization.CultureInfo.In
 28204            ["hashAlgorithm"] = HashAlgorithm,
 28205            ["previousLinkHash"] = PreviousLinkHash,
 28206            ["recordHash"] = RecordHash,
 28207            ["recordId"] = RecordId,
 28208            ["recordType"] = RecordType,
 28209            ["sequence"] = Sequence
 28210        };
 211
 28212        return CanonicalPayload.Create(
 28213            CanonicalArtifactTypes.AuditIntegrityLink,
 28214            $"{ChainId}:{Sequence}",
 28215            SchemaVersion,
 28216            CanonicalizationVersion,
 28217            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    {
 19228        ArgumentNullException.ThrowIfNull(recordHash);
 229
 18230        string schemaVersion = AsiBackboneSchemaVersions.StableArtifactsV1;
 18231        string canonicalizationVersion = recordHash.CanonicalizationVersion;
 18232        var draft = new AuditIntegrityLink(
 18233            chainId,
 18234            sequence,
 18235            recordHash.ArtifactId,
 18236            recordHash.ArtifactType,
 18237            recordHash.HashValue,
 18238            previousLinkHash,
 18239            recordHash.HashValue,
 18240            recordHash.HashAlgorithm,
 18241            canonicalizationVersion,
 18242            schemaVersion,
 18243            createdUtc,
 18244            NormalizeMetadata(metadata));
 245
 18246        string linkHash = CanonicalPayloadHasher.ComputeHash(draft.BuildCanonicalPayload()).HashValue;
 247
 18248        return new AuditIntegrityLink(
 18249            draft.ChainId,
 18250            draft.Sequence,
 18251            draft.RecordId,
 18252            draft.RecordType,
 18253            draft.RecordHash,
 18254            draft.PreviousLinkHash,
 18255            linkHash,
 18256            draft.HashAlgorithm,
 18257            draft.CanonicalizationVersion,
 18258            draft.SchemaVersion,
 18259            draft.CreatedUtc,
 18260            draft.Metadata);
 261    }
 262
 263    private static IReadOnlyDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
 264    {
 27265        if (metadata is null || metadata.Count == 0)
 266        {
 24267            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}