diff --git a/src/OrasProject.Oras/Content/Digest.cs b/src/OrasProject.Oras/Content/Digest.cs index 8c02ac7e..68d9f231 100644 --- a/src/OrasProject.Oras/Content/Digest.cs +++ b/src/OrasProject.Oras/Content/Digest.cs @@ -13,7 +13,6 @@ using OrasProject.Oras.Content.Exceptions; using System; -using System.Collections.Generic; using System.Security.Cryptography; using System.Text.RegularExpressions; @@ -21,28 +20,42 @@ namespace OrasProject.Oras.Content; internal static partial class Digest { - // Regular expression pattern for validating digest strings - // The pattern matches the following format: - // : - // Explanation: - // - ^[a-z0-9]+: The algorithm part, consisting of lowercase letters - // and digits, followed by a colon. - // - (?:[.+_-][a-z0-9]+)*: Optional segments of a dot, plus, underscore, - // or hyphen followed by lowercase letters and digits. - // - :[a-zA-Z0-9=_-]+$: The digest part, consisting of base64url-encoded - // characters (letters, digits, equals, underscore, hyphen). - // For more details, refer to the distribution specification: + // Registered algorithm identifiers per OCI image-spec v1.1.1. + internal const string AlgorithmSha256 = "sha256"; + internal const string AlgorithmSha512 = "sha512"; + + // Validation error constants for programmatic consumption. + internal const string ErrDigestEmpty = "Invalid digest. Digest string is empty."; + internal const string ErrDigestInvalidFormat = "Invalid digest format."; + internal const string ErrSha256InvalidEncoded = "Invalid sha256 digest. Encoded portion must be exactly 64 lowercase hex characters."; + internal const string ErrSha512InvalidEncoded = "Invalid sha512 digest. Encoded portion must be exactly 128 lowercase hex characters."; + + // General digest grammar per OCI image-spec v1.1.1: + // digest ::= algorithm ":" encoded + // algorithm ::= algorithm-component (algorithm-separator algorithm-component)* + // algorithm-component ::= [a-z0-9]+ + // algorithm-separator ::= [+._-] + // encoded ::= [a-zA-Z0-9=_-]+ // https://github.com/opencontainers/image-spec/blob/v1.1.1/descriptor.md#digests [GeneratedRegex(@"^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$", RegexOptions.Compiled)] private static partial Regex DigestRegex(); - // List of registered and supported algorithms as per the specification - private static readonly HashSet _supportedAlgorithms = ["sha256", "sha512"]; + // SHA-256 encoded portion: exactly 64 lowercase hex characters. + // Per OCI image-spec v1.1.1: "the encoded portion MUST match /[a-f0-9]{64}/" + [GeneratedRegex(@"^[a-f0-9]{64}$", RegexOptions.Compiled)] + private static partial Regex Sha256EncodedRegex(); + + // SHA-512 encoded portion: exactly 128 lowercase hex characters. + // Per OCI image-spec v1.1.1: "the encoded portion MUST match /[a-f0-9]{128}/" + [GeneratedRegex(@"^[a-f0-9]{128}$", RegexOptions.Compiled)] + private static partial Regex Sha512EncodedRegex(); /// - /// Verifies the digest header and throws an exception if it is invalid. + /// Verifies the digest string and throws an exception if it is invalid. /// - /// + /// The digest string to validate. + /// The validated digest string. + /// Thrown when the digest is invalid. internal static string Validate(string digest) { return TryValidate(digest, out var error) @@ -50,25 +63,53 @@ internal static string Validate(string digest) : throw new InvalidDigestException(error); } + /// + /// Validates a digest string per OCI image-spec v1.1.1. + /// Registered algorithms (sha256, sha512) are validated strictly. + /// Unrecognized algorithms pass if they match the general grammar. + /// + /// The digest string to validate. + /// When this method returns false, contains the validation error. + /// true if valid; otherwise, false. internal static bool TryValidate(string digest, out string error) { if (string.IsNullOrEmpty(digest)) { - error = "Digest is null or empty"; + error = ErrDigestEmpty; return false; } if (!DigestRegex().IsMatch(digest)) { - error = $"Invalid digest: {digest}"; + error = $"{ErrDigestInvalidFormat} The digest '{digest}' does not match the required grammar."; return false; } - var algorithm = digest.Split(':')[0]; - if (!_supportedAlgorithms.Contains(algorithm)) + var colonIndex = digest.IndexOf(':'); + var algorithm = digest[..colonIndex]; + var encoded = digest[(colonIndex + 1)..]; + + // For registered algorithms, enforce per-algorithm encoded format + // per OCI image-spec v1.1.1 descriptor.md#registered-algorithms. + // Unrecognized algorithms pass validation if they match the general + // grammar per spec: "Implementations SHOULD allow digests with + // unrecognized algorithms to pass validation if they comply with + // the above grammar." + if (algorithm == AlgorithmSha256) { - error = $"Unrecognized, unregistered or unsupported digest algorithm: {algorithm}"; - return false; + if (!Sha256EncodedRegex().IsMatch(encoded)) + { + error = $"{ErrSha256InvalidEncoded} Got '{digest}'."; + return false; + } + } + else if (algorithm == AlgorithmSha512) + { + if (!Sha512EncodedRegex().IsMatch(encoded)) + { + error = $"{ErrSha512InvalidEncoded} Got '{digest}'."; + return false; + } } error = string.Empty; @@ -78,12 +119,12 @@ internal static bool TryValidate(string digest, out string error) /// /// Generates a SHA-256 digest from a byte array. /// - /// - /// + /// The content to hash. + /// The digest string in the format "sha256:<hex>". internal static string ComputeSha256(byte[] content) { var hash = SHA256.HashData(content); - var output = $"sha256:{Convert.ToHexString(hash)}"; - return output.ToLower(); + var output = $"{AlgorithmSha256}:{Convert.ToHexString(hash)}"; + return output.ToLowerInvariant(); } } diff --git a/src/OrasProject.Oras/Exceptions/InvalidDescriptorException.cs b/src/OrasProject.Oras/Exceptions/InvalidDescriptorException.cs new file mode 100644 index 00000000..124875fd --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/InvalidDescriptorException.cs @@ -0,0 +1,37 @@ +// Copyright The ORAS Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace OrasProject.Oras.Exceptions; + +/// +/// InvalidDescriptorException is thrown when a descriptor fails validation +/// per OCI image-spec requirements. +/// +public class InvalidDescriptorException : FormatException +{ + public InvalidDescriptorException() + { + } + + public InvalidDescriptorException(string? message) + : base(message) + { + } + + public InvalidDescriptorException(string? message, Exception? inner) + : base(message, inner) + { + } +} diff --git a/src/OrasProject.Oras/Oci/Descriptor.cs b/src/OrasProject.Oras/Oci/Descriptor.cs index 8d4147f3..d22ed87e 100644 --- a/src/OrasProject.Oras/Oci/Descriptor.cs +++ b/src/OrasProject.Oras/Oci/Descriptor.cs @@ -14,15 +14,20 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using OrasProject.Oras.Exceptions; namespace OrasProject.Oras.Oci; /// /// Descriptor describes a content addressable blob. -/// Specification: https://github.com/opencontainers/image-spec/blob/v1.1.0/descriptor.md +/// Specification: https://github.com/opencontainers/image-spec/blob/v1.1.1/descriptor.md /// public class Descriptor { + // Validation error constants for programmatic consumption. + internal const string ErrMediaTypeEmpty = "Invalid descriptor. The 'mediaType' property must not be empty."; + internal const string ErrSizeNegative = "Invalid descriptor. The 'size' property must be non-negative."; + [JsonPropertyName("mediaType")] public required string MediaType { get; set; } @@ -75,7 +80,47 @@ public static Descriptor Create(Span data, string mediaType) internal static bool IsNullOrInvalid(Descriptor? descriptor) { - return descriptor == null || string.IsNullOrWhiteSpace(descriptor.Digest) || string.IsNullOrWhiteSpace(descriptor.MediaType); + return descriptor == null || string.IsNullOrWhiteSpace(descriptor.Digest) || string.IsNullOrWhiteSpace(descriptor.MediaType) || descriptor.Size < 0; + } + + /// + /// Validates the descriptor per OCI image-spec v1.1.1 requirements. + /// Checks mediaType (non-empty), size (non-negative), and digest format. + /// + /// When this method returns false, contains the validation error message. + /// true if the descriptor is valid; otherwise, false. + public bool TryValidate(out string error) + { + if (string.IsNullOrWhiteSpace(MediaType)) + { + error = ErrMediaTypeEmpty; + return false; + } + + if (Size < 0) + { + error = ErrSizeNegative; + return false; + } + + return Content.Digest.TryValidate(Digest, out error); + } + + /// + /// Validates the descriptor per OCI image-spec v1.1.1 requirements. + /// Checks mediaType (non-empty), size (non-negative), and digest format. + /// + /// + /// In performance-sensitive code paths, prefer to avoid + /// the cost of exception allocation and stack unwinding on invalid input. + /// + /// Thrown when validation fails. + public void Validate() + { + if (!TryValidate(out var error)) + { + throw new InvalidDescriptorException(error); + } } /// diff --git a/src/OrasProject.Oras/Registry/Reference.cs b/src/OrasProject.Oras/Registry/Reference.cs index 8b277406..e66cdcb5 100644 --- a/src/OrasProject.Oras/Registry/Reference.cs +++ b/src/OrasProject.Oras/Registry/Reference.cs @@ -436,7 +436,7 @@ private static string ValidateReferenceAsTag(string reference) reference : throw new InvalidReferenceException(error); } - private static bool TryValidateTag(string reference, out string error) + internal static bool TryValidateTag(string reference, out string error) { if (string.IsNullOrEmpty(reference)) { diff --git a/src/OrasProject.Oras/Registry/Remote/Referrers.cs b/src/OrasProject.Oras/Registry/Remote/Referrers.cs index 0a033453..fb57c05c 100644 --- a/src/OrasProject.Oras/Registry/Remote/Referrers.cs +++ b/src/OrasProject.Oras/Registry/Remote/Referrers.cs @@ -15,6 +15,7 @@ using System.Linq; using OrasProject.Oras.Content; using OrasProject.Oras.Oci; +using OrasProject.Oras.Registry.Exceptions; namespace OrasProject.Oras.Registry.Remote; @@ -37,9 +38,26 @@ internal enum ReferrerOperation Delete, } + /// + /// Builds a referrers tag from a descriptor's digest by replacing ':' with '-'. + /// Validates both the digest format and the resulting tag format. + /// + /// The descriptor whose digest is used to build the tag. + /// The referrers tag string. + /// Thrown if the digest is invalid. + /// Thrown if the resulting tag is not a valid OCI tag. internal static string BuildReferrersTag(Descriptor descriptor) { - return Digest.Validate(descriptor.Digest).Replace(':', '-'); + if (!Digest.TryValidate(descriptor.Digest, out var digestError)) + { + throw new Content.Exceptions.InvalidDigestException(digestError); + } + var tag = descriptor.Digest.Replace(':', '-'); + if (!Reference.TryValidateTag(tag, out var tagError)) + { + throw new InvalidReferenceException(tagError); + } + return tag; } /// diff --git a/tests/OrasProject.Oras.Tests/Content/ContentTest.cs b/tests/OrasProject.Oras.Tests/Content/ContentTest.cs index 3f2dad69..15ede95b 100644 --- a/tests/OrasProject.Oras.Tests/Content/ContentTest.cs +++ b/tests/OrasProject.Oras.Tests/Content/ContentTest.cs @@ -37,7 +37,7 @@ public void VerifiesIfDigestMatches() /// [Theory] [InlineData("sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b")] - [InlineData("sha512:401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b372742c513925d98f76b340d9e59a4efdc45db9f5c640a21831b3d08be")] + [InlineData("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")] public void Validate_ReturnsDigest_ForRegisteredAlgorithms(string validDigest) { var result = Digest.Validate(validDigest); @@ -45,15 +45,19 @@ public void Validate_ReturnsDigest_ForRegisteredAlgorithms(string validDigest) } /// - /// This method tests if the digest validation throws an exception for unregistered or unsupported algorithms + /// This method tests if the digest validation passes for unrecognized algorithms + /// that match the general digest grammar, per OCI image-spec v1.1.1: + /// "Implementations SHOULD allow digests with unrecognized algorithms to pass + /// validation if they comply with the above grammar." /// [Theory] - [InlineData("md5:098f6bcd4621d373cade4e832627b4f6")] // MD5, unregistered digest - [InlineData("sha1:3b8b5a6b79f6d1114a7b7e95b3e3bc74dd1b6a2a")] // SHA-1, unregistered digest - [InlineData("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8")] // Multihash, unregistered digest - public void Validate_ThrowsException_ForUnregisteredAlgorithms(string invalidDigest) + [InlineData("md5:098f6bcd4621d373cade4e832627b4f6")] + [InlineData("sha1:3b8b5a6b79f6d1114a7b7e95b3e3bc74dd1b6a2a")] + [InlineData("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8")] + public void Validate_ReturnsDigest_ForUnrecognizedAlgorithms(string digest) { - Assert.Throws(() => Digest.Validate(invalidDigest)); + var result = Digest.Validate(digest); + Assert.Equal(digest, result); } /// diff --git a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs index b8e99f1b..0336e8a2 100644 --- a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs +++ b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs @@ -65,4 +65,12 @@ public async Task InvalidDateTimeFormatException() await Assert.ThrowsAsync(() => throw new InvalidDateTimeFormatException("Invalid date time format")); await Assert.ThrowsAsync(() => throw new InvalidDateTimeFormatException("Invalid date time format", null)); } + + [Fact] + public async Task InvalidDescriptorException() + { + await Assert.ThrowsAsync(() => throw new InvalidDescriptorException()); + await Assert.ThrowsAsync(() => throw new InvalidDescriptorException("Invalid descriptor")); + await Assert.ThrowsAsync(() => throw new InvalidDescriptorException("Invalid descriptor", null)); + } } diff --git a/tests/OrasProject.Oras.Tests/Registry/Remote/ReferrersTest.cs b/tests/OrasProject.Oras.Tests/Registry/Remote/ReferrersTest.cs index 9fec8bbb..f85dd092 100644 --- a/tests/OrasProject.Oras.Tests/Registry/Remote/ReferrersTest.cs +++ b/tests/OrasProject.Oras.Tests/Registry/Remote/ReferrersTest.cs @@ -13,6 +13,7 @@ using OrasProject.Oras.Content.Exceptions; using OrasProject.Oras.Oci; +using OrasProject.Oras.Registry.Exceptions; using OrasProject.Oras.Registry.Remote; using static OrasProject.Oras.Tests.Remote.Util.Util; using static OrasProject.Oras.Tests.Remote.Util.RandomDataGenerator; @@ -38,6 +39,16 @@ public void BuildReferrersTag_ShouldThrowInvalidDigestException() Assert.Throws(() => Referrers.BuildReferrersTag(desc)); } + [Fact] + public void BuildReferrersTag_ShouldThrowInvalidReferenceException_WhenTagIsInvalid() + { + var desc = RandomDescriptor(); + // A digest with '+' in the algorithm produces a valid digest per OCI grammar + // but an invalid tag after ':' -> '-' replacement ('+' is not in [\w.-]) + desc.Digest = "multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Rg8"; + Assert.Throws(() => Referrers.BuildReferrersTag(desc)); + } + [Fact] public void ApplyReferrerChanges_ShouldAddNewReferrers() { diff --git a/tests/OrasProject.Oras.Tests/Serialization/ManifestSerializationTest.Negative.cs b/tests/OrasProject.Oras.Tests/Serialization/ManifestSerializationTest.Negative.cs index 155e6574..0e705682 100644 --- a/tests/OrasProject.Oras.Tests/Serialization/ManifestSerializationTest.Negative.cs +++ b/tests/OrasProject.Oras.Tests/Serialization/ManifestSerializationTest.Negative.cs @@ -13,6 +13,7 @@ using System.Text; using System.Text.Json; +using OrasProject.Oras.Exceptions; using OrasProject.Oras.Oci; using OrasProject.Oras.Serialization; using Xunit; @@ -130,4 +131,169 @@ public void Deserialize_UnknownFieldInDescriptor_Succeeds() Assert.NotNull(descriptor); Assert.Equal(1024, descriptor!.Size); } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData(":44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a")] + public void Descriptor_Validate_InvalidDigest_ThrowsInvalidDescriptorException(string invalidDigest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = invalidDigest + }; + + Assert.Throws(() => descriptor.Validate()); + } + + // Per OCI image-spec v1.1.1: sha256 encoded MUST match /[a-f0-9]{64}/ + [Theory] + [InlineData("sha256:tooshort")] + [InlineData("sha256:44136FA355B3678A1146AD16F7E8649E94FB4FC21FE77E8310C060F61CAAFF8A")] // uppercase not allowed + [InlineData("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8")] // 63 chars + [InlineData("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8aa")] // 65 chars + [InlineData("sha512:tooshort")] + [InlineData("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3")] // 127 chars + public void Descriptor_Validate_InvalidRegisteredDigest_ThrowsInvalidDescriptorException(string invalidDigest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = invalidDigest + }; + + Assert.Throws(() => descriptor.Validate()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Descriptor_TryValidate_EmptyMediaType_ReturnsFalse(string mediaType) + { + var descriptor = new Descriptor + { + MediaType = mediaType, + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + }; + + var result = descriptor.TryValidate(out var error); + + Assert.False(result); + Assert.Equal(Descriptor.ErrMediaTypeEmpty, error); + } + + [Fact] + public void Descriptor_TryValidate_NegativeSize_ReturnsFalse() + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = -1 + }; + + var result = descriptor.TryValidate(out var error); + + Assert.False(result); + Assert.Equal(Descriptor.ErrSizeNegative, error); + } + + [Theory] + [InlineData("invalid")] + [InlineData(":44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a")] + public void Descriptor_TryValidate_InvalidDigestFormat_ErrorContainsDigest(string invalidDigest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = invalidDigest + }; + + var result = descriptor.TryValidate(out var error); + + Assert.False(result); + Assert.Contains(invalidDigest, error); + } + + [Theory] + [InlineData("sha256:tooshort")] + [InlineData("sha256:44136FA355B3678A1146AD16F7E8649E94FB4FC21FE77E8310C060F61CAAFF8A")] + [InlineData("sha512:tooshort")] + public void Descriptor_TryValidate_InvalidRegisteredDigest_ErrorContainsDigest(string invalidDigest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = invalidDigest + }; + + var result = descriptor.TryValidate(out var error); + + Assert.False(result); + Assert.Contains(invalidDigest, error); + } + + [Fact] + public void Descriptor_TryValidate_EmptyDigest_ReturnsFalse() + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = "" + }; + + var result = descriptor.TryValidate(out var error); + + Assert.False(result); + Assert.NotEmpty(error); + } + + // Per OCI spec: unrecognized algorithms SHOULD pass if they match the general grammar + [Theory] + [InlineData("multihash+base58:QmRZxt2b1FVZPNqd8hsiykDL3TdBDeTSPX9Kv46HmX4Gx8")] + [InlineData("sha256+b64u:LCa0a2j_xo_5m0U8HTBBNBNCLXBkg7-g-YpeiGJm564")] + public void Descriptor_TryValidate_UnrecognizedAlgorithm_ReturnsTrue(string digest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = digest + }; + + var result = descriptor.TryValidate(out var error); + + Assert.True(result); + Assert.Empty(error); + } + + [Theory] + [InlineData("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a")] + [InlineData("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")] + public void Descriptor_TryValidate_ValidDigest_ReturnsTrue(string validDigest) + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = validDigest + }; + + var result = descriptor.TryValidate(out var error); + + Assert.True(result); + Assert.Empty(error); + } + + [Fact] + public void Descriptor_Validate_ValidDescriptor_DoesNotThrow() + { + var descriptor = new Descriptor + { + MediaType = "application/vnd.oci.image.layer.v1.tar+gzip", + Digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + Size = 1024 + }; + + descriptor.Validate(); // should not throw + } }