Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 67 additions & 26 deletions src/OrasProject.Oras/Content/Digest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,62 +13,103 @@

using OrasProject.Oras.Content.Exceptions;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.RegularExpressions;

namespace OrasProject.Oras.Content;

internal static partial class Digest
{
// Regular expression pattern for validating digest strings
// The pattern matches the following format:
// <algorithm>:<base64url-encoded digest>
// 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<string> _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();

/// <summary>
/// Verifies the digest header and throws an exception if it is invalid.
/// Verifies the digest string and throws an exception if it is invalid.
/// </summary>
/// <param name="digest"></param>
/// <param name="digest">The digest string to validate.</param>
/// <returns>The validated digest string.</returns>
/// <exception cref="InvalidDigestException">Thrown when the digest is invalid.</exception>
internal static string Validate(string digest)
{
return TryValidate(digest, out var error)
? digest
: throw new InvalidDigestException(error);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="digest">The digest string to validate.</param>
/// <param name="error">When this method returns false, contains the validation error.</param>
/// <returns>true if valid; otherwise, false.</returns>
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;
Expand All @@ -78,12 +119,12 @@ internal static bool TryValidate(string digest, out string error)
/// <summary>
/// Generates a SHA-256 digest from a byte array.
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
/// <param name="content">The content to hash.</param>
/// <returns>The digest string in the format "sha256:&lt;hex&gt;".</returns>
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();
}
}
37 changes: 37 additions & 0 deletions src/OrasProject.Oras/Exceptions/InvalidDescriptorException.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// InvalidDescriptorException is thrown when a descriptor fails validation
/// per OCI image-spec requirements.
/// </summary>
public class InvalidDescriptorException : FormatException
{
public InvalidDescriptorException()
{
}

public InvalidDescriptorException(string? message)
: base(message)
{
}

public InvalidDescriptorException(string? message, Exception? inner)
: base(message, inner)
{
}
}
49 changes: 47 additions & 2 deletions src/OrasProject.Oras/Oci/Descriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using OrasProject.Oras.Exceptions;

namespace OrasProject.Oras.Oci;

/// <summary>
/// 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
/// </summary>
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; }

Expand Down Expand Up @@ -75,7 +80,47 @@ public static Descriptor Create(Span<byte> 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;
}

/// <summary>
/// Validates the descriptor per OCI image-spec v1.1.1 requirements.
/// Checks mediaType (non-empty), size (non-negative), and digest format.
/// </summary>
/// <param name="error">When this method returns false, contains the validation error message.</param>
/// <returns>true if the descriptor is valid; otherwise, false.</returns>
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);
}

/// <summary>
/// Validates the descriptor per OCI image-spec v1.1.1 requirements.
/// Checks mediaType (non-empty), size (non-negative), and digest format.
/// </summary>
/// <remarks>
/// In performance-sensitive code paths, prefer <see cref="TryValidate"/> to avoid
/// the cost of exception allocation and stack unwinding on invalid input.
/// </remarks>
/// <exception cref="InvalidDescriptorException">Thrown when validation fails.</exception>
public void Validate()
{
if (!TryValidate(out var error))
{
throw new InvalidDescriptorException(error);
}
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/OrasProject.Oras/Registry/Reference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down
20 changes: 19 additions & 1 deletion src/OrasProject.Oras/Registry/Remote/Referrers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,9 +38,26 @@ internal enum ReferrerOperation
Delete,
}

/// <summary>
/// Builds a referrers tag from a descriptor's digest by replacing ':' with '-'.
/// Validates both the digest format and the resulting tag format.
/// </summary>
/// <param name="descriptor">The descriptor whose digest is used to build the tag.</param>
Comment on lines +41 to +45
/// <returns>The referrers tag string.</returns>
/// <exception cref="OrasProject.Oras.Content.Exceptions.InvalidDigestException">Thrown if the digest is invalid.</exception>
/// <exception cref="InvalidReferenceException">Thrown if the resulting tag is not a valid OCI tag.</exception>
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;
}

/// <summary>
Expand Down
18 changes: 11 additions & 7 deletions tests/OrasProject.Oras.Tests/Content/ContentTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,27 @@ public void VerifiesIfDigestMatches()
/// </summary>
[Theory]
[InlineData("sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b")]
[InlineData("sha512:401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b372742c513925d98f76b340d9e59a4efdc45db9f5c640a21831b3d08be")]
[InlineData("sha512:cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")]
public void Validate_ReturnsDigest_ForRegisteredAlgorithms(string validDigest)
Comment on lines 38 to 41
{
var result = Digest.Validate(validDigest);
Assert.Equal(validDigest, result);
}

/// <summary>
/// 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."
/// </summary>
[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<InvalidDigestException>(() => Digest.Validate(invalidDigest));
var result = Digest.Validate(digest);
Assert.Equal(digest, result);
}

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,12 @@ public async Task InvalidDateTimeFormatException()
await Assert.ThrowsAsync<InvalidDateTimeFormatException>(() => throw new InvalidDateTimeFormatException("Invalid date time format"));
await Assert.ThrowsAsync<InvalidDateTimeFormatException>(() => throw new InvalidDateTimeFormatException("Invalid date time format", null));
}

[Fact]
public async Task InvalidDescriptorException()
{
await Assert.ThrowsAsync<InvalidDescriptorException>(() => throw new InvalidDescriptorException());
await Assert.ThrowsAsync<InvalidDescriptorException>(() => throw new InvalidDescriptorException("Invalid descriptor"));
await Assert.ThrowsAsync<InvalidDescriptorException>(() => throw new InvalidDescriptorException("Invalid descriptor", null));
}
}
11 changes: 11 additions & 0 deletions tests/OrasProject.Oras.Tests/Registry/Remote/ReferrersTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,16 @@ public void BuildReferrersTag_ShouldThrowInvalidDigestException()
Assert.Throws<InvalidDigestException>(() => 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<InvalidReferenceException>(() => Referrers.BuildReferrersTag(desc));
}

[Fact]
public void ApplyReferrerChanges_ShouldAddNewReferrers()
{
Expand Down
Loading
Loading