File: DigestUtils.cs
Web Access
Project: src\src\sdk\src\Containers\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj (Microsoft.NET.Build.Containers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;
using System.Text.RegularExpressions;

namespace Microsoft.NET.Build.Containers;

internal sealed class DigestUtils
{
    /// <summary>
    /// The set of registered algorithm identifiers from the OCI image-spec,
    /// mapped to the regex pattern that the encoded portion of the digest must
    /// match. Taken directly from the OCI image specification:
    /// <see href="https://github.com/opencontainers/image-spec/blob/a4c6ade7bb82b316d45391f572727a63e268b252/descriptor.md#registered-algorithms">
    /// Registered Algorithms
    /// </see>
    ///
    /// The OCI specification also defines sha512 and blake3 as optional
    /// registered algorithms. They are not included here because the rest of
    /// the containers pipeline (blob storage paths, digest creation, content
    /// verification) is currently SHA-256 only. Supporting additional
    /// algorithms requires changes across ContentStore, Layer, ImageBuilder,
    /// and the registry push/pull paths.
    /// </summary>
    private static readonly Dictionary<string, Regex> s_registeredAlgorithms = new(StringComparer.Ordinal)
    {
        // TODO: Use GeneratedRegexAttribute when
        // https://github.com/dotnet/sdk/pull/53547 is merged.
        ["sha256"] = new Regex(@"^[a-f0-9]{64}$"),
    };

    /// <summary>
    /// Computes the SHA-256 digest of <paramref name="content"/> and returns
    /// the full digest string.
    /// </summary>
    /// <remarks>
    /// <c>ComputeSha256Digest("")</c> returns <c>"sha256:e3b0c4..."</c>.
    /// </remarks>
    internal static string ComputeSha256Digest(string content) => FormatSha256Digest(ComputeSha256(content));

    /// <summary>
    /// Formats a SHA-256 digest string from an already-computed encoded hash
    /// value. The encoded value is lowercased to conform to the OCI spec.
    /// Throws <see cref="ArgumentException"/> if <paramref name="encoded"/>
    /// is not exactly 64 hex characters.
    /// </summary>
    /// <remarks>
    /// <c>FormatSha256Digest("abcdef...")</c> returns
    /// <c>"sha256:abcdef..."</c>.
    /// </remarks>
    internal static string FormatSha256Digest(string encoded)
    {
        encoded = encoded.ToLowerInvariant();

        if (!s_registeredAlgorithms["sha256"].IsMatch(encoded))
        {
            throw new ArgumentException(
                message: $"SHA-256 value '{encoded}' does not match expected format '{s_registeredAlgorithms["sha256"]}'",
                paramName: nameof(encoded));
        }

        return $"sha256:{encoded}";
    }

    /// <summary>
    /// Validates a digest string against the OCI grammar and registered
    /// algorithms. Throws <see cref="InvalidDigestException"/> if the digest
    /// is invalid.
    /// </summary>
    /// <remarks>
    /// <c>ValidateDigest("sha256:e3b0c4...")</c> succeeds.
    /// </remarks>
    internal static void ValidateDigest(string digest) => ValidateAndParseDigest(digest, out _, out _);

    /// <summary>
    /// Validates a digest string against the OCI grammar and registered
    /// algorithms, then returns the encoded portion.
    /// </summary>
    /// <remarks>
    /// <c>GetEncoded("sha256:e3b0c4...")</c> returns <c>"e3b0c4..."</c>.
    /// </remarks>
    internal static string GetEncoded(string digest)
    {
        ValidateAndParseDigest(digest, out _, out string encoded);
        return encoded;
    }

    /// <summary>
    /// Computes the SHA-256 hash of <paramref name="content"/> and returns it
    /// as a lowercase hex string.
    /// </summary>
    /// <remarks>
    /// <c>ComputeSha256("")</c> returns <c>"e3b0c4..."</c>.
    /// </remarks>
    internal static string ComputeSha256(string content)
    {
        Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
        SHA256.HashData(Encoding.UTF8.GetBytes(content), hash);
        return Convert.ToHexStringLower(hash);
    }

    /// <summary>
    /// Validates a digest string against the OCI grammar and registered
    /// algorithms, returning the parsed algorithm and encoded portions. Throws
    /// <see cref="InvalidDigestException"/> if the digest is malformed, uses an
    /// unsupported algorithm, or the encoded value does not match the
    /// algorithm's expected format.
    /// </summary>
    /// <remarks>
    /// <c>ValidateAndParseDigest("sha256:e3b0c4...", out algorithm, out encoded)</c>
    /// sets <c>algorithm</c> to <c>"sha256"</c> and <c>encoded</c> to <c>"e3b0c4..."</c>.
    /// </remarks>
    private static void ValidateAndParseDigest(string digest, out string algorithm, out string encoded)
    {
        Match match = ReferenceParser.AnchoredDigestRegexp.Match(digest);

        if (!match.Success)
        {
            throw new InvalidDigestException(
                $"Digest '{digest}' does not match expected pattern '{ReferenceParser.AnchoredDigestRegexp}'.");
        }

        algorithm = match.Groups[1].Value;
        encoded = match.Groups[2].Value;

        if (!s_registeredAlgorithms.TryGetValue(algorithm, out Regex? encodedPattern))
        {
            string supportedAlgorithms = string.Join(", ", s_registeredAlgorithms.Keys);
            throw new InvalidDigestException(
                $"Unsupported digest algorithm '{algorithm}'. Supported algorithms: {supportedAlgorithms}.");
        }

        if (!encodedPattern.IsMatch(encoded))
        {
            throw new InvalidDigestException(
                $"Digest '{digest}' encoded value does not match expected pattern for algorithm '{algorithm}': '{encodedPattern}'.");
        }
    }
}