< Summary

Information
Class: AsiBackbone.Samples.NcatAuditCompletionAdapter.NcatAuditCompletionContract
Assembly: AsiBackbone.Samples.NcatAuditCompletionAdapter
File(s): /home/runner/work/AsiBackbone/AsiBackbone/samples/NcatAuditCompletionAdapter/NcatAuditCompletionContract.cs
Line coverage
95%
Covered lines: 100
Uncovered lines: 5
Coverable lines: 105
Total lines: 245
Line coverage: 95.2%
Branch coverage
79%
Covered branches: 49
Total branches: 62
Branch coverage: 79%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ComputeIdempotencyKey(...)100%11100%
TryCreateHandoff(...)100%22100%
TryVerifyCanonicalManifest(...)78.57%141490.48%
Validate(...)95%2020100%
IsSha256Hex(...)100%44100%
IsValidDestination(...)100%44100%
HasWhitespaceOnlyIdentifier(...)100%11100%
DigestMatches(...)50%2275%
TryReadManifestHeader(...)50%161689.47%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/samples/NcatAuditCompletionAdapter/NcatAuditCompletionContract.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Security.Cryptography;
 3using System.Text;
 4using System.Text.Json;
 5
 6namespace AsiBackbone.Samples.NcatAuditCompletionAdapter;
 7
 8/// <summary>
 9/// Applies version 1 of NCAT's published audit-completion contract to a received completion message.
 10/// </summary>
 11/// <remarks>
 12/// The rules are modeled independently from NCAT and are checked against NCAT's pinned contract vectors in
 13/// the sample tests. Validation rejects anything the contract does not define instead of guessing a mapping.
 14/// </remarks>
 15public static class NcatAuditCompletionContract
 16{
 17    /// <summary>Gets the major contract version this adapter understands.</summary>
 18    public const int SupportedContractMajorVersion = 1;
 19
 20    /// <summary>Gets the supported completion message schema version.</summary>
 21    public const string MessageSchemaVersion = "1.0";
 22
 23    /// <summary>Gets the supported canonical mutation manifest schema version.</summary>
 24    public const string ManifestSchemaVersion = "1.0";
 25
 26    /// <summary>Gets the supported canonical mutation manifest digest algorithm.</summary>
 27    public const string ManifestAlgorithm = "SHA-256";
 28
 29    /// <summary>Gets the only persistence outcome carried by an NCAT completion message.</summary>
 30    public const string CommittedOutcome = "Committed";
 31
 32    /// <summary>Gets the prefix of every NCAT completion idempotency key.</summary>
 33    public const string IdempotencyKeyPrefix = "ncat-audit-completion:";
 34
 35    /// <summary>Gets the maximum destination length accepted by NCAT staging.</summary>
 36    public const int MaximumDestinationLength = 128;
 37
 38    private const int Sha256HexLength = 64;
 39
 40    /// <summary>
 41    /// Derives the idempotency key NCAT assigns to a destination and mutation batch.
 42    /// </summary>
 43    /// <remarks>
 44    /// The key hashes the trimmed destination and the trimmed mutation batch identifier. The message itself keeps
 45    /// the batch identifier exactly as NCAT recorded it.
 46    /// </remarks>
 47    public static string ComputeIdempotencyKey(string destination, string mutationBatchId)
 48    {
 1449        ArgumentNullException.ThrowIfNull(destination);
 1450        ArgumentNullException.ThrowIfNull(mutationBatchId);
 51
 1452        byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(destination.Trim() + "\n" + mutationBatchId.Trim()));
 1453        return IdempotencyKeyPrefix + Convert.ToHexString(digest);
 54    }
 55
 56    /// <summary>
 57    /// Validates a completion message against the contract and translates it into an adapter handoff.
 58    /// </summary>
 59    /// <returns><see langword="true" /> when the message satisfies the contract; otherwise <see langword="false" />.</r
 60    public static bool TryCreateHandoff(
 61        NcatAuditCompletionMessage message,
 62        int deliveryAttempt,
 63        [NotNullWhen(true)] out NcatAuditCompletionHandoff? handoff,
 64        [NotNullWhen(false)] out string? reasonCode)
 65    {
 2566        ArgumentNullException.ThrowIfNull(message);
 2567        handoff = null;
 68
 2569        reasonCode = Validate(message);
 2570        if (reasonCode is not null)
 71        {
 1672            return false;
 73        }
 74
 975        handoff = new NcatAuditCompletionHandoff(
 976            CompletionEntryId: message.IdempotencyKey,
 977            PersistenceOutcome: message.PersistenceOutcome,
 978            CompletedUtc: message.ReceiptCompletedUtc.ToUniversalTime(),
 979            OperationExecutionId: message.OperationExecutionId,
 980            ExecutionAttemptId: message.ExecutionAttemptId,
 981            DecisionAuditRecordId: message.DecisionAuditRecordId,
 982            CorrelationId: message.CorrelationId,
 983            TraceId: message.TraceId,
 984            MutationBatchId: message.MutationBatchId,
 985            AuditRecordCount: message.AuditRecordCount,
 986            MutationManifestHash: message.MutationManifestHash,
 987            MutationManifestAlgorithm: message.MutationManifestAlgorithm,
 988            DeliveryAttempt: deliveryAttempt);
 989        return true;
 90    }
 91
 92    /// <summary>
 93    /// Verifies a message against retained canonical manifest bytes, such as an archive's copy of the manifest.
 94    /// </summary>
 95    /// <param name="message">The completion message to verify.</param>
 96    /// <param name="canonicalManifestUtf8">The exact canonical manifest text, encoded as UTF-8 without a byte order mar
 97    /// <param name="reasonCode">The rejection reason when verification fails.</param>
 98    /// <returns><see langword="true" /> when the manifest corresponds to the message; otherwise <see langword="false" /
 99    public static bool TryVerifyCanonicalManifest(
 100        NcatAuditCompletionMessage message,
 101        ReadOnlySpan<byte> canonicalManifestUtf8,
 102        [NotNullWhen(false)] out string? reasonCode)
 103    {
 14104        ArgumentNullException.ThrowIfNull(message);
 105
 14106        if (!string.Equals(message.MutationManifestAlgorithm, ManifestAlgorithm, StringComparison.Ordinal))
 107        {
 0108            reasonCode = "unsupported-manifest-algorithm";
 0109            return false;
 110        }
 111
 14112        if (!IsSha256Hex(message.MutationManifestHash))
 113        {
 4114            reasonCode = "invalid-manifest-hash";
 4115            return false;
 116        }
 117
 10118        if (!TryReadManifestHeader(canonicalManifestUtf8, out ManifestHeader? header))
 119        {
 4120            reasonCode = "manifest-malformed";
 4121            return false;
 122        }
 123
 6124        reasonCode =
 6125            !string.Equals(header.SchemaVersion, message.MutationManifestSchemaVersion, StringComparison.Ordinal)
 6126                ? "manifest-schema-version-mismatch"
 6127            : !string.Equals(header.MutationBatchId, message.MutationBatchId, StringComparison.Ordinal)
 6128                ? "manifest-batch-id-mismatch"
 6129            : header.AuditRecordCount != message.AuditRecordCount
 6130                ? "manifest-record-count-mismatch"
 6131            : !DigestMatches(message.MutationManifestHash, SHA256.HashData(canonicalManifestUtf8))
 6132                ? "manifest-digest-mismatch"
 6133            : null;
 134
 6135        return reasonCode is null;
 136    }
 137
 138    private static string? Validate(NcatAuditCompletionMessage message)
 139    {
 25140        return !string.Equals(message.SchemaVersion, MessageSchemaVersion, StringComparison.Ordinal)
 25141                ? "unsupported-message-schema-version"
 25142            : !string.Equals(message.MutationManifestSchemaVersion, ManifestSchemaVersion, StringComparison.Ordinal)
 25143                ? "unsupported-manifest-schema-version"
 25144            : !string.Equals(message.MutationManifestAlgorithm, ManifestAlgorithm, StringComparison.Ordinal)
 25145                ? "unsupported-manifest-algorithm"
 25146            : !string.Equals(message.PersistenceOutcome, CommittedOutcome, StringComparison.Ordinal)
 25147                ? "unsupported-persistence-outcome"
 25148            : !IsSha256Hex(message.MutationManifestHash)
 25149                ? "invalid-manifest-hash"
 25150            : string.IsNullOrWhiteSpace(message.MutationBatchId)
 25151                ? "mutation-batch-id-required"
 25152            : message.AuditRecordCount < 1
 25153                ? "audit-record-count-invalid"
 25154            : !IsValidDestination(message.Destination)
 25155                ? "invalid-destination"
 25156            : HasWhitespaceOnlyIdentifier(message)
 25157                ? "malformed-optional-identifier"
 25158            : !string.Equals(
 25159                message.IdempotencyKey,
 25160                ComputeIdempotencyKey(message.Destination, message.MutationBatchId),
 25161                StringComparison.Ordinal)
 25162                ? "idempotency-key-mismatch"
 25163            : null;
 164    }
 165
 166    // Version 1 of the contract encodes digests as uppercase hex, so lowercase a-f is not a v1 digest.
 167    private static bool IsSha256Hex(string? value)
 168    {
 37169        return value is { Length: Sha256HexLength } && value.All(char.IsAsciiHexDigitUpper);
 170    }
 171
 172    private static bool IsValidDestination(string? destination)
 173    {
 12174        return !string.IsNullOrWhiteSpace(destination) &&
 12175            destination.Length <= MaximumDestinationLength &&
 12176            string.Equals(destination, destination.Trim(), StringComparison.Ordinal);
 177    }
 178
 179    private static bool HasWhitespaceOnlyIdentifier(NcatAuditCompletionMessage message)
 180    {
 11181        string?[] identifiers =
 11182        [
 11183            message.OperationExecutionId,
 11184            message.ExecutionAttemptId,
 11185            message.DecisionAuditRecordId,
 11186            message.CorrelationId,
 11187            message.TraceId
 11188        ];
 189
 11190        return identifiers.Any(identifier => identifier is not null && string.IsNullOrWhiteSpace(identifier));
 191    }
 192
 193    private static bool DigestMatches(string expectedHex, byte[] actualDigest)
 194    {
 5195        if (!IsSha256Hex(expectedHex))
 196        {
 0197            return false;
 198        }
 199
 5200        byte[] expected = Convert.FromHexString(expectedHex);
 5201        return CryptographicOperations.FixedTimeEquals(expected, actualDigest);
 202    }
 203
 204    private static bool TryReadManifestHeader(
 205        ReadOnlySpan<byte> canonicalManifestUtf8,
 206        [NotNullWhen(true)] out ManifestHeader? header)
 207    {
 10208        header = null;
 209
 210        try
 211        {
 10212            Utf8JsonReader reader = new(canonicalManifestUtf8);
 10213            using var document = JsonDocument.ParseValue(ref reader);
 214
 215            // A canonical manifest is exactly one JSON value. Trailing whitespace is skipped by the reader;
 216            // any further token means the digest covers content other than the manifest.
 10217            if (reader.Read())
 218            {
 0219                return false;
 220            }
 221
 6222            JsonElement root = document.RootElement;
 223
 6224            if (root.ValueKind != JsonValueKind.Object ||
 6225                !root.TryGetProperty("schemaVersion", out JsonElement schemaVersion) ||
 6226                schemaVersion.ValueKind != JsonValueKind.String ||
 6227                !root.TryGetProperty("mutationBatchId", out JsonElement mutationBatchId) ||
 6228                mutationBatchId.ValueKind != JsonValueKind.String ||
 6229                !root.TryGetProperty("auditRecordCount", out JsonElement auditRecordCount) ||
 6230                !auditRecordCount.TryGetInt32(out int count))
 231            {
 0232                return false;
 233            }
 234
 6235            header = new ManifestHeader(schemaVersion.GetString()!, mutationBatchId.GetString()!, count);
 6236            return true;
 237        }
 4238        catch (JsonException)
 239        {
 4240            return false;
 241        }
 10242    }
 243
 244    private sealed record ManifestHeader(string SchemaVersion, string MutationBatchId, int AuditRecordCount);
 245}