diff --git a/src/OrasProject.Oras/Exceptions/InvalidReferenceException.cs b/src/OrasProject.Oras/Exceptions/InvalidReferenceException.cs index 886cad2..7916646 100644 --- a/src/OrasProject.Oras/Exceptions/InvalidReferenceException.cs +++ b/src/OrasProject.Oras/Exceptions/InvalidReferenceException.cs @@ -16,7 +16,7 @@ namespace OrasProject.Oras.Exceptions; /// -/// InvalidReferenceException is thrown when the reference is invlid +/// InvalidReferenceException is thrown when the reference is invalid. /// public class InvalidReferenceException : FormatException { diff --git a/src/OrasProject.Oras/Exceptions/InvalidResponseException.cs b/src/OrasProject.Oras/Exceptions/InvalidResponseException.cs new file mode 100644 index 0000000..dd525d1 --- /dev/null +++ b/src/OrasProject.Oras/Exceptions/InvalidResponseException.cs @@ -0,0 +1,36 @@ +// 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; + +/// +/// InvalidResponseException is thrown when the response is invalid. +/// +public class InvalidResponseException : FormatException +{ + public InvalidResponseException() + { + } + + public InvalidResponseException(string? message) + : base(message) + { + } + + public InvalidResponseException(string? message, Exception? inner) + : base(message, inner) + { + } +} diff --git a/src/OrasProject.Oras/Registry/Reference.cs b/src/OrasProject.Oras/Registry/Reference.cs index f52ca3a..d7b7de1 100644 --- a/src/OrasProject.Oras/Registry/Reference.cs +++ b/src/OrasProject.Oras/Registry/Reference.cs @@ -200,7 +200,7 @@ public static bool TryParse(string reference, [NotNullWhen(true)] out Reference? return false; } } - + public Reference(Reference other) { if (other == null) diff --git a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs index efae9e9..8877412 100644 --- a/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs +++ b/src/OrasProject.Oras/Registry/Remote/ManifestStore.cs @@ -14,8 +14,6 @@ using OrasProject.Oras.Exceptions; using OrasProject.Oras.Oci; using System; -using System.Collections.Generic; -using System.Collections.Immutable; using System.IO; using System.Net; using System.Net.Http; @@ -274,7 +272,7 @@ private async Task UpdateReferrersIndex(Descriptor subject, { // 1. pull the original referrers index list using referrers tag schema var referrersTag = Referrers.BuildReferrersTag(subject); - var (oldDesc, oldReferrers) = await PullReferrersIndexList(referrersTag, cancellationToken).ConfigureAwait(false); + var (oldDesc, oldReferrers) = await Repository.PullReferrersIndexList(referrersTag, cancellationToken).ConfigureAwait(false); // 2. apply the referrer change to referrers list var (updatedReferrers, updateRequired) = @@ -309,34 +307,6 @@ private async Task UpdateReferrersIndex(Descriptor subject, await DeleteAsync(oldDesc, cancellationToken).ConfigureAwait(false); } - /// - /// PullReferrersIndexList retrieves the referrers index list associated with the given referrers tag. - /// It fetches the index manifest from the repository, deserializes it into an `Index` object, - /// and returns the descriptor along with the list of manifests (referrers). If the referrers index is not found, - /// an empty descriptor and an empty list are returned. - /// - /// - /// - /// - internal async Task<(Descriptor?, IList)> PullReferrersIndexList(String referrersTag, CancellationToken cancellationToken = default) - { - try - { - var (desc, content) = await FetchAsync(referrersTag, cancellationToken).ConfigureAwait(false); - var index = JsonSerializer.Deserialize(content); - if (index == null) - { - throw new JsonException($"null index manifests list when pulling referrers index list for referrers tag {referrersTag}"); - } - return (desc, index.Manifests); - } - catch (NotFoundException) - { - return (null, ImmutableArray.Empty); - } - } - - /// /// Pushes the manifest content, matching the expected descriptor. /// diff --git a/src/OrasProject.Oras/Registry/Remote/Referrers.cs b/src/OrasProject.Oras/Registry/Remote/Referrers.cs index 951650e..6efca7a 100644 --- a/src/OrasProject.Oras/Registry/Remote/Referrers.cs +++ b/src/OrasProject.Oras/Registry/Remote/Referrers.cs @@ -12,6 +12,7 @@ // limitations under the License. using System.Collections.Generic; +using System.Linq; using OrasProject.Oras.Content; using OrasProject.Oras.Oci; @@ -105,4 +106,40 @@ internal static (IList, bool) ApplyReferrerChanges(IList return (updatedReferrers, updateRequired); } + + /// + /// IsReferrersFilterApplied checks if requstedFilter is in the applied filters list. + /// + /// + /// + /// + internal static bool IsReferrersFilterApplied(string? appliedFilters, string requestedFilter) { + if (string.IsNullOrEmpty(appliedFilters) || string.IsNullOrEmpty(requestedFilter)) + { + return false; + } + + var filters = appliedFilters.Split(","); + foreach (var filter in filters) + { + if (filter == requestedFilter) + { + return true; + } + } + + return false; + } + + /// + /// FilterReferrers filters out a list of referrers based on the specified artifact type + /// + /// + /// + /// + internal static IList FilterReferrers(IList referrers, string? artifactType) + { + return string.IsNullOrEmpty(artifactType) ? referrers : referrers.Where(referrer => referrer.ArtifactType == artifactType).ToList(); + } } + diff --git a/src/OrasProject.Oras/Registry/Remote/Repository.cs b/src/OrasProject.Oras/Registry/Remote/Repository.cs index a3a59c1..bd423ea 100644 --- a/src/OrasProject.Oras/Registry/Remote/Repository.cs +++ b/src/OrasProject.Oras/Registry/Remote/Repository.cs @@ -15,6 +15,7 @@ using OrasProject.Oras.Oci; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Net; @@ -25,6 +26,8 @@ using System.Threading; using System.Threading.Tasks; using System.Web; +using OrasProject.Oras.Content; +using Index = OrasProject.Oras.Oci.Index; namespace OrasProject.Oras.Registry.Remote; @@ -48,6 +51,14 @@ public class Repository : IRepository public RepositoryOptions Options => _opts; private int _referrersState = (int) Referrers.ReferrersState.Unknown; + + /// + /// _filterTypeArtifactType is the "artifactType" filter applied on the list of referrers. + /// + /// References: + /// - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers + /// + private const string _filterTypeArtifactType = "artifactType"; /// /// ReferrersState indicates the Referrers API state of the remote repository. @@ -66,6 +77,21 @@ internal Referrers.ReferrersState ReferrersState } } + /// + /// ReferrerListPageSize specifies the page size when invoking the Referrers API. + /// If zero, the page size is determined by the remote registry. + /// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers + /// + public int ReferrerListPageSize; + + /// + /// _headerOciFiltersApplied is the "OCI-Filters-Applied" header. + /// If present on the response, it contains a comma-separated list of the applied filters. + /// Reference: + /// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers + /// + private const string _headerOciFiltersApplied = "OCI-Filters-Applied"; + internal static readonly string[] DefaultManifestMediaTypes = [ Docker.MediaType.Manifest, @@ -371,6 +397,195 @@ internal Reference ParseReferenceFromContentReference(string reference) public async Task MountAsync(Descriptor descriptor, string fromRepository, Func>? getContent = null, CancellationToken cancellationToken = default) => await ((IMounter)Blobs).MountAsync(descriptor, fromRepository, getContent, cancellationToken).ConfigureAwait(false); + /// + /// ReferrersAsync retrieves referrers for the given descriptor and artifact type if specified + /// and return a streaming of descriptors asynchronously for consumption. + /// If referrers API is not supported, the function falls back to a tag schema for retrieving referrers. + /// If the referrers are supported via an API, the state is updated accordingly. + /// + /// + /// + /// + public async IAsyncEnumerable ReferrersAsync(Descriptor descriptor, string? artifactType, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (ReferrersState == Referrers.ReferrersState.NotSupported) + { + // fall back to tag schema to retrieve referrers + await foreach (var referrer in ReferrersByTagSchema(descriptor, artifactType, cancellationToken) + .ConfigureAwait(false)) + { + yield return referrer; + } + + yield break; + } + + // referrers state is unknown or supported + await foreach (var referrer in ReferrersByApi(descriptor, artifactType, cancellationToken) + .ConfigureAwait(false)) + { + // If Referrers API is supported, then it would return referrers continuously + // otherwise, this line of code is not executed + // and the ReferrerState would be set to false in the method ReferrersByApi. + yield return referrer; + } + + if (ReferrersState == Referrers.ReferrersState.NotSupported) + { + // referrers state is set to NotSupported by ReferrersByApi, fall back to tag schema to retrieve referrers + await foreach (var referrer in ReferrersByTagSchema(descriptor, artifactType, cancellationToken) + .ConfigureAwait(false)) + { + yield return referrer; + } + } + } + + /// + /// ReferrersByApi retrieves a collection of referrers asynchronously based on the given descriptor and optional artifact type. + /// + /// + /// + /// + /// + /// + /// + internal async IAsyncEnumerable ReferrersByApi(Descriptor descriptor, string? artifactType, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var reference = new Reference(Options.Reference) + { + ContentReference = descriptor.Digest + }; + var nextPageUrl = new UriFactory(reference).BuildReferrersUrl(artifactType); + + while (nextPageUrl != null) + { + // If ReferrerListPageSize is greater than 0, modify the URL to include the page size query parameter + if (ReferrerListPageSize > 0) + { + var uriBuilder = new UriBuilder(nextPageUrl); + var query = HttpUtility.ParseQueryString(uriBuilder.Query); + query.Add("n", ReferrerListPageSize.ToString()); + uriBuilder.Query = query.ToString(); + nextPageUrl = uriBuilder.Uri; + } + + using var response = await _opts.HttpClient.GetAsync(nextPageUrl, cancellationToken).ConfigureAwait(false); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + // If the status code is OK, continue processing the response + break; + case HttpStatusCode.NotFound: + // If the status code is NotFound, handle as an error, possibly a non-existent repository + var exception = await response.ParseErrorResponseAsync(cancellationToken) + .ConfigureAwait(false); + if (exception.Errors?.First().Code == nameof(ResponseException.ErrorCode.NAME_UNKNOWN)) + { + // Repository is not found, Referrers API status is unknown + // Propagate the exception to the caller + throw exception; + } + + // Set ReferrerState to false and return earlier + SetReferrersState(false); + yield break; + default: + // For any other status code, parse and throw the error response + throw await response.ParseErrorResponseAsync(cancellationToken) + .ConfigureAwait(false); + } + + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (mediaType != MediaType.ImageIndex) + { + // Referrers API is not properly supported, set it to false and return early + SetReferrersState(false); + yield break; + } + + using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var referrersIndex = JsonSerializer.Deserialize(content) ?? throw new InvalidResponseException( + $"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: failed to decode response"); + + // Set ReferrerState to Supported + SetReferrersState(true); + + var referrers = referrersIndex.Manifests; + // If artifactType is specified, apply any filters based on the artifact type + if (!string.IsNullOrEmpty(artifactType)) + { + if (!response.Headers.TryGetValues(_headerOciFiltersApplied, out var values) + || !Referrers.IsReferrersFilterApplied(values.FirstOrDefault(), _filterTypeArtifactType)) + { + // Filter the referrers based on the artifact type if necessary + referrers = Referrers.FilterReferrers(referrers, artifactType); + } + } + + foreach (var referrer in referrers) + { + // return referrer if any + yield return referrer; + } + + // update nextPageUrl + nextPageUrl = response.ParseLink(); + } + } + + /// + /// ReferrersByTagSchema retrieves referrers based on referrers tag schema, filters out referrers based on specified artifact type + /// and return a collection of referrers asynchronously when referrers API is not supported. + /// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#backwards-compatibility + /// + /// + /// + /// + internal async IAsyncEnumerable ReferrersByTagSchema(Descriptor descriptor, string? artifactType, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var referrersTag = Referrers.BuildReferrersTag(descriptor); + var (_, referrers) = await PullReferrersIndexList(referrersTag, cancellationToken).ConfigureAwait(false); + var filteredReferrers = Referrers.FilterReferrers(referrers, artifactType); + foreach (var referrer in filteredReferrers) + { + yield return referrer; + } + } + + /// + /// PullReferrersIndexList retrieves the referrers index list associated with the given referrers tag. + /// It fetches the index manifest from the repository, deserializes it into an `Index` object, + /// and returns the descriptor along with the list of manifests (referrers). If the referrers index is not found, + /// an empty descriptor and an empty list are returned. + /// + /// + /// + /// + internal async Task<(Descriptor?, IList)> PullReferrersIndexList(String referrersTag, + CancellationToken cancellationToken = default) + { + try + { + var result = await FetchAsync(referrersTag, cancellationToken).ConfigureAwait(false); + LimitSize(result.Descriptor, Options.MaxMetadataBytes); + using (var stream = result.Stream) + { + var indexBytes = await stream.ReadAllAsync(result.Descriptor, cancellationToken).ConfigureAwait(false); + var index = JsonSerializer.Deserialize(indexBytes) ?? throw new JsonException( + $"error when deserialize index manifest for referrersTag {referrersTag}"); + return (result.Descriptor, index.Manifests); + } + } + catch (NotFoundException) + { + return (null, ImmutableArray.Empty); + } + } + /// /// PingReferrersAsync returns true if the Referrers API is available for the repository, /// otherwise returns false diff --git a/src/OrasProject.Oras/Registry/Remote/UriFactory.cs b/src/OrasProject.Oras/Registry/Remote/UriFactory.cs index 88c678e..29ebc07 100644 --- a/src/OrasProject.Oras/Registry/Remote/UriFactory.cs +++ b/src/OrasProject.Oras/Registry/Remote/UriFactory.cs @@ -102,10 +102,11 @@ public Uri BuildRepositoryBlobUpload() builder.Path += "/blobs/uploads/"; return builder.Uri; } - + /// - /// Builds the URL for accessing the Referrers API - /// Format: :///v2//referrers/?artifactType= + /// BuildReferrersUrl builds the URL for accessing referrers API + /// Format: :///v2//referrers/?artifactType= + /// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#listing-referrers /// /// /// @@ -119,7 +120,7 @@ public Uri BuildReferrersUrl(string? artifactType = null) query.Add("artifactType", artifactType); builder.Query = query.ToString(); } - + return builder.Uri; } diff --git a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs index c00ce67..76d023b 100644 --- a/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs +++ b/tests/OrasProject.Oras.Tests/Exceptions/ExceptionTest.cs @@ -57,6 +57,16 @@ public async Task ReferrersSupportLevelAlreadySetException() await Assert.ThrowsAsync(() => throw new ReferrersStateAlreadySetException("Referrers state has already been set")); await Assert.ThrowsAsync(() => throw new ReferrersStateAlreadySetException("Referrers state has already been set", null)); } + + [Fact] + public async Task InvalidResponseException() + { + await Assert.ThrowsAsync(() => throw new InvalidResponseException()); + await Assert.ThrowsAsync(() => + throw new InvalidResponseException("Invalid response")); + await Assert.ThrowsAsync(() => + throw new InvalidResponseException("Invalid response", null)); + } [Fact] public async Task SizeLimitExceededException() diff --git a/tests/OrasProject.Oras.Tests/Remote/ManifestStoreTest.cs b/tests/OrasProject.Oras.Tests/Remote/ManifestStoreTest.cs index b151142..87db2c3 100644 --- a/tests/OrasProject.Oras.Tests/Remote/ManifestStoreTest.cs +++ b/tests/OrasProject.Oras.Tests/Remote/ManifestStoreTest.cs @@ -23,7 +23,6 @@ using static OrasProject.Oras.Content.Digest; using Index = OrasProject.Oras.Oci.Index; using Xunit; -using Xunit.Abstractions; namespace OrasProject.Oras.Tests.Remote; @@ -72,8 +71,7 @@ public async Task ManifestStore_PullReferrersIndexListSuccessfully() PlainHttp = true, }); var cancellationToken = new CancellationToken(); - var store = new ManifestStore(repo); - var (receivedDesc, receivedManifests) = await store.PullReferrersIndexList(expectedIndexDesc.Digest, cancellationToken); + var (receivedDesc, receivedManifests) = await repo.PullReferrersIndexList(expectedIndexDesc.Digest, cancellationToken); Assert.True(AreDescriptorsEqual(expectedIndexDesc, receivedDesc)); for (var i = 0; i < receivedManifests.Count; ++i) { @@ -81,6 +79,51 @@ public async Task ManifestStore_PullReferrersIndexListSuccessfully() } } + [Fact] + public async Task ManifestStore_PullReferrersIndexList_ExceedSizeLimit() + { + var expectedIndex = RandomIndex(); + var expectedIndexBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(expectedIndex)); + var expectedIndexDesc = new Descriptor() + { + Digest = ComputeSHA256(expectedIndexBytes), + MediaType = MediaType.ImageIndex, + Size = expectedIndexBytes.Length + }; + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + if (req.RequestUri?.AbsolutePath == $"/v2/test/manifests/{expectedIndexDesc.Digest}") + { + if (req.Headers.TryGetValues("Accept", out IEnumerable? values) && !values.Contains(MediaType.ImageIndex)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + } + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { expectedIndexDesc.Digest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + MaxMetadataBytes = expectedIndexDesc.Size - 1 + }); + var cancellationToken = new CancellationToken(); + var exception = await Assert.ThrowsAsync(async () => await repo.PullReferrersIndexList(expectedIndexDesc.Digest, cancellationToken)); + Assert.Equal($"content size {expectedIndexDesc.Size} exceeds MaxMetadataBytes {repo.Options.MaxMetadataBytes}", exception.Message); + } + [Fact] public async Task ManifestStore_PullReferrersIndexListNotFound() { @@ -101,8 +144,7 @@ public async Task ManifestStore_PullReferrersIndexListNotFound() PlainHttp = true, }); var cancellationToken = new CancellationToken(); - var store = new ManifestStore(repo); - var (receivedDesc, receivedManifests) = await store.PullReferrersIndexList("test", cancellationToken); + var (receivedDesc, receivedManifests) = await repo.PullReferrersIndexList("test", cancellationToken); Assert.Null(receivedDesc); Assert.Empty(receivedManifests); } diff --git a/tests/OrasProject.Oras.Tests/Remote/ReferrersTest.cs b/tests/OrasProject.Oras.Tests/Remote/ReferrersTest.cs index ed01505..2201480 100644 --- a/tests/OrasProject.Oras.Tests/Remote/ReferrersTest.cs +++ b/tests/OrasProject.Oras.Tests/Remote/ReferrersTest.cs @@ -267,4 +267,121 @@ public void ApplyReferrerChanges_NoUpdateWhenOldAndNewReferrersAreEmpty() Assert.Empty(updatedReferrers); Assert.False(updateRequired); } + + [Fact] + public void IsReferrersFilterApplied_AppliedFiltersNull_ReturnsFalse() + { + string? appliedFilters = null; + const string requestedFilter = "artifactType"; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.False(result); + } + + [Fact] + public void IsReferrersFilterApplied_AppliedFiltersEmpty_ReturnsFalse() + { + const string appliedFilters = ""; + const string requestedFilter = "artifactType"; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.False(result); + } + + [Fact] + public void IsReferrersFilterApplied_RequestedFilterNull_ReturnsFalse() + { + const string appliedFilters = "artifactType,annotation"; + string? requestedFilter = null; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.False(result); + } + + [Fact] + public void IsReferrersFilterApplied_RequestedFilterEmpty_ReturnsFalse() + { + const string appliedFilters = "artifactType,annotation"; + const string requestedFilter = ""; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.False(result); + } + + [Fact] + public void IsReferrersFilterApplied_RequestedFilterMatches_ReturnsTrue() + { + const string appliedFilters = "artifactType,annotation"; + const string requestedFilter = "artifactType"; + + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.True(result); + } + + [Fact] + public void IsReferrersFilterApplied_SingleAppliedFiltersRequestedFilterMatches_ReturnsTrue() + { + const string appliedFilters = "filter1"; + const string requestedFilter = "filter1"; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.True(result); + } + + [Fact] + public void IsReferrersFilterApplied_RequestedFilterDoesNotMatch_ReturnsFalse() + { + const string appliedFilters = "filter1,filter2"; + const string requestedFilter = "filter3"; + var result = Referrers.IsReferrersFilterApplied(appliedFilters, requestedFilter); + Assert.False(result); + } + + [Fact] + public void FilterReferrers_WithNullOrEmptyArtifactType_ShouldReturnAllReferrers() + { + var referrers = new List + { + RandomDescriptor(), + RandomDescriptor(), + RandomDescriptor(), + }; + string? artifactType = null; + var result = Referrers.FilterReferrers(referrers, artifactType); + Assert.Equal(3, result.Count); + Assert.Equal(referrers, result); + + artifactType = ""; + result = Referrers.FilterReferrers(referrers, artifactType); + Assert.Equal(3, result.Count); + Assert.Equal(referrers, result); + } + + [Fact] + public void FilterReferrers_WithValidArtifactType_ShouldReturnMatchingReferrers() + { + var referrers = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"abc/abc"), + }; + const string artifactType = "doc/example"; + var result = Referrers.FilterReferrers(referrers, artifactType); + + Assert.Equal(2, result.Count); + Assert.True(result.All(r => r.ArtifactType == artifactType)); + } + + [Fact] + public void FilterReferrers_WithArtifactTypeThatDoesNotExist_ShouldReturnEmptyList() + { + var referrers = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"abc/abc"), + }; + const string artifactType = "NonExistentType"; + var result = Referrers.FilterReferrers(referrers, artifactType); + Assert.Empty(result); + } + } diff --git a/tests/OrasProject.Oras.Tests/Remote/RepositoryTest.cs b/tests/OrasProject.Oras.Tests/Remote/RepositoryTest.cs index 8968496..17d7286 100644 --- a/tests/OrasProject.Oras.Tests/Remote/RepositoryTest.cs +++ b/tests/OrasProject.Oras.Tests/Remote/RepositoryTest.cs @@ -23,6 +23,7 @@ using System.Text.RegularExpressions; using System.Web; using Xunit; +using Xunit.Abstractions; using static OrasProject.Oras.Content.Digest; using static OrasProject.Oras.Tests.Remote.Util.Util; using static OrasProject.Oras.Tests.Remote.Util.RandomDataGenerator; @@ -41,10 +42,18 @@ public struct TestIOStruct public bool ErrExpectedOnGET; } + private ITestOutputHelper _iTestOutputHelper; + public RepositoryTest(ITestOutputHelper iTestOutputHelper) + { + _iTestOutputHelper = iTestOutputHelper; + } private byte[] _theAmazingBanClan = "Ban Gu, Ban Chao, Ban Zhao"u8.ToArray(); private const string _theAmazingBanDigest = "b526a4f2be963a2f9b0990c001255669eab8a254ab1a6e3f84f1820212ac7078"; private const string _dockerContentDigestHeader = "Docker-Content-Digest"; + + private const string _headerOciFiltersApplied = "OCI-Filters-Applied"; + // The following truth table aims to cover the expected GET/HEAD request outcome // for all possible permutations of the client/server "containing a digest", for @@ -2592,6 +2601,306 @@ public void SetReferrersState_ShouldNotThrowException_WhenSettingSameValue() var exception = Record.Exception(() => repo.ReferrersState = Referrers.ReferrersState.Supported); Assert.Null(exception); } + + [Fact] + public async Task Repository_ReferrersByTagSchema_Successfully() + { + var referrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(referrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + var expectedIndexDesc = new Descriptor() + { + Digest = ComputeSHA256(expectedIndexBytes), + MediaType = MediaType.ImageIndex, + }; + + var desc = RandomDescriptor(); + var referrersTag = Referrers.BuildReferrersTag(desc); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + if (req.RequestUri?.AbsolutePath == $"/v2/test/manifests/{referrersTag}") + { + if (req.Headers.TryGetValues("Accept", out IEnumerable? values) && !values.Contains(MediaType.ImageIndex)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + } + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { expectedIndexDesc.Digest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // no artifact type filtering + var cancellationToken = new CancellationToken(); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByTagSchema(desc, null, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + Assert.Equivalent(expectedIndex.Manifests, returnedReferrers1); + + // filter out referrers with artifact type doc/example + var artifactType2 = "doc/example"; + var returnedReferrers2 = new List(); + await foreach (var referrer in repo.ReferrersByTagSchema(desc, artifactType2, cancellationToken)) + { + returnedReferrers2.Add(referrer); + } + Assert.True(returnedReferrers2.All(referrer => referrer.ArtifactType == artifactType2)); + Assert.Equal(3, returnedReferrers2.Count); + + // filter out non-existent referrers with artifact type non/non + var artifactType3 = "non/non"; + var returnedReferrers3 = new List(); + await foreach (var referrer in repo.ReferrersByTagSchema(desc, artifactType3, cancellationToken)) + { + returnedReferrers3.Add(referrer); + } + Assert.Empty(returnedReferrers3); + } + + [Fact] + public async Task Repository_ReferrersByTagSchema_ReferrersNotFound() + { + var desc = RandomDescriptor(); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + var cancellationToken = new CancellationToken(); + + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByTagSchema(desc, null, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + Assert.Empty(returnedReferrers1); + } + + [Fact] + public async Task Repository_ReferrersByApi_SinglePageWithoutFilter_Successfully() + { + var expectedReferrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(expectedReferrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + + var desc = RandomDescriptor(); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + if (req.RequestUri?.AbsolutePath == $"/v2/test/referrers/{desc.Digest}") + { + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { desc.Digest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // no artifact type specified + var cancellationToken = new CancellationToken(); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByApi(desc, null, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + Assert.Equivalent(expectedReferrersList, returnedReferrers1); + } + + [Fact] + public async Task Repository_ReferrersByApi_SinglePageWithServerSideFilter_Successfully() + { + var expectedReferrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(expectedReferrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + const string artifactType = "doc/example"; + var desc = RandomDescriptor(); + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + var expectedQuery = HttpUtility.ParseQueryString(""); + expectedQuery["artifactType"] = artifactType; + var expectedQueryString = expectedQuery.ToString(); + + if (req.RequestUri?.PathAndQuery == $"/v2/test/referrers/{desc.Digest}?{expectedQueryString}") + { + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { desc.Digest }); + res.Headers.Add(_headerOciFiltersApplied, new string[] {"artifactType"}); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // filter out referrers with artifact type "doc/example" + var cancellationToken = new CancellationToken(); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByApi(desc, artifactType, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + + Assert.True(returnedReferrers1.All(referrer => referrer.ArtifactType == artifactType)); + Assert.Equal(3, returnedReferrers1.Count); + } + + [Fact] + public async Task Repository_ReferrersByApi_SinglePageWithClientSideFilter_Successfully() + { + var expectedReferrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(expectedReferrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + const string artifactType = "doc/example"; + var desc = RandomDescriptor(); + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + var expectedQuery = HttpUtility.ParseQueryString(""); + expectedQuery["artifactType"] = artifactType; + var expectedQueryString = expectedQuery.ToString(); + + if (req.RequestUri?.PathAndQuery == $"/v2/test/referrers/{desc.Digest}?{expectedQueryString}") + { + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { desc.Digest }); + res.Headers.Add(_headerOciFiltersApplied, new string[] {"abc/abc"}); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // filter out referrers with artifact type "doc/example" + var cancellationToken = new CancellationToken(); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByApi(desc, artifactType, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + Assert.True(returnedReferrers1.All(referrer => referrer.ArtifactType == artifactType)); + Assert.Equal(3, returnedReferrers1.Count); + } + + [Fact] + public async Task Repository_ReferrersByApi_ThrowsNotSupportedException_WhenReferrersApiNotSupported() + { + var desc = RandomDescriptor(); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersByApi(desc, null, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + + Assert.Empty(returnedReferrers1); + } [Fact] public async Task PingReferrers_ShouldReturnTrueWhenReferrersAPISupported() @@ -2718,6 +3027,435 @@ public async Task PingReferrers_ShouldReturnFalseWhenReferrersAPINotSupportedNoC Assert.Equal(Referrers.ReferrersState.NotSupported, repo.ReferrersState); } + [Fact] + public async Task Repository_ReferrersByApi_ThrowsResponseException_WhenRepoNotFound() + { + var desc = RandomDescriptor(); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + res.Content = new StringContent(@"{ ""errors"": [ { ""code"": ""NAME_UNKNOWN"", ""message"": ""some error"" } ] }"); + res.StatusCode = HttpStatusCode.NotFound; + return res; + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var isInvoked = false; + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in repo.ReferrersByApi(desc, null, cancellationToken)) + { + isInvoked = true; + } + }); + + Assert.False(isInvoked); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + Assert.NotNull(exception.Errors); + Assert.Single(exception.Errors); + Assert.Equal("NAME_UNKNOWN", exception.Errors[0].Code); + Assert.Equal("some error", exception.Errors[0].Message); + } + + [Fact] + public async Task Repository_ReferrersByApi_ThrowsResponseException_WhenServerErrors() + { + var desc = RandomDescriptor(); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + res.Content = new StringContent(@"{ ""errors"": [ { ""code"": ""INTERNAL_SERVER_ERROR"", ""message"": ""some error"" } ] }"); + res.StatusCode = HttpStatusCode.InternalServerError; + return res; + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var isInvoked = false; + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in repo.ReferrersByApi(desc, null, cancellationToken)) + { + isInvoked = true; + } + }); + Assert.False(isInvoked); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + Assert.NotNull(exception.Errors); + Assert.Single(exception.Errors); + Assert.Equal("INTERNAL_SERVER_ERROR", exception.Errors[0].Code); + Assert.Equal("some error", exception.Errors[0].Message); + } + + [Fact] + public async Task Repository_ReferrersByApi_WhenContentIsNotImageIndex() + { + var expectedReferrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(expectedReferrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + const string artifactType = "doc/example"; + var desc = RandomDescriptor(); + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + var expectedQuery = HttpUtility.ParseQueryString(""); + expectedQuery["artifactType"] = artifactType; + var expectedQueryString = expectedQuery.ToString(); + + if (req.RequestUri?.PathAndQuery == $"/v2/test/referrers/{desc.Digest}?{expectedQueryString}") + { + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageManifest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var cancellationToken = new CancellationToken(); + var returnedReferrers = new List(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + await foreach (var referrer in repo.ReferrersByApi(desc, artifactType, cancellationToken)) + { + returnedReferrers.Add(referrer); + } + Assert.Equal(Referrers.ReferrersState.NotSupported, repo.ReferrersState); + Assert.Empty(returnedReferrers); + } + + [Fact] + public async Task Repository_ReferrersAsync_SinglePage_ReferrersApiSupported() + { + var expectedReferrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(expectedReferrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + const string artifactType = "doc/example"; + var desc = RandomDescriptor(); + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + var expectedQuery = HttpUtility.ParseQueryString(""); + expectedQuery["artifactType"] = artifactType; + var expectedQueryString = expectedQuery.ToString(); + + if (req.RequestUri?.PathAndQuery == $"/v2/test/referrers/{desc.Digest}?{expectedQueryString}") + { + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { desc.Digest }); + res.Headers.Add(_headerOciFiltersApplied, new string[] {"abc/abc"}); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // filter out referrers with artifact type "doc/example" + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var returnedReferrers = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, artifactType, cancellationToken)) + { + returnedReferrers.Add(referrer); + } + Assert.True(returnedReferrers.All(referrer => referrer.ArtifactType == artifactType)); + Assert.Equal(3, returnedReferrers.Count); + Assert.Equal(Referrers.ReferrersState.Supported, repo.ReferrersState); + } + + [Fact] + public async Task Repository_ReferrersAsync_MultiplePages_ReferrersApiSupported() + { + var expectedReferrersList = new List> + { + new (){ + RandomDescriptor(artifactType: "first/example"), + RandomDescriptor(artifactType: "first/example"), + RandomDescriptor(artifactType: "first/example"), + RandomDescriptor(artifactType: "first/example") + }, + new (){ + RandomDescriptor(artifactType: "second/example"), + RandomDescriptor(artifactType: "second/example"), + RandomDescriptor(artifactType: "second/example"), + RandomDescriptor(artifactType: "second/example") + }, + new (){ + RandomDescriptor(artifactType: "third/example"), + RandomDescriptor(artifactType: "third/example"), + RandomDescriptor(artifactType: "third/example"), + RandomDescriptor(artifactType: "third/example") + }, + + }; + + var desc = RandomDescriptor(); + + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + var path = $"/v2/test/referrers/{desc.Digest}"; + IList? referrers = null; + if (req.RequestUri?.AbsolutePath == path) + { + var query = HttpUtility.ParseQueryString(req.RequestUri.Query); + switch (query.Get("test")) + { + case "foo": + res.Headers.Add("Link", new string?[]{ $"<{req.RequestUri.Scheme}://{req.RequestUri.Host}{path}?test=bar>; rel=\"next\"" }); + referrers = expectedReferrersList.ElementAtOrDefault(1); + break; + case "bar": + referrers = expectedReferrersList.ElementAtOrDefault(2); + break; + default: + referrers = expectedReferrersList.ElementAtOrDefault(0); + res.Headers.Add("Link", new string?[]{ $"<{path}?test=foo>; rel=\"next\"" }); + break; + } + + var expectedIndex = RandomIndex(referrers); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { desc.Digest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var returnedReferrers = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, null, cancellationToken)) + { + returnedReferrers.Add(referrer); + } + Assert.Equivalent(expectedReferrersList.SelectMany(list => list).ToList(), returnedReferrers); + Assert.Equal(Referrers.ReferrersState.Supported, repo.ReferrersState); + } + + [Fact] + public async Task Repository_ReferrersAsync_WhenReferrersApiNotSupported() + { + var referrersList = new List + { + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"abc/abc"), + RandomDescriptor(artifactType:"doc/example"), + RandomDescriptor(artifactType:"doc/example"), + }; + var expectedIndex = RandomIndex(referrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + var expectedIndexDesc = new Descriptor() + { + Digest = ComputeSHA256(expectedIndexBytes), + MediaType = MediaType.ImageIndex, + }; + + var desc = RandomDescriptor(); + var referrersTag = Referrers.BuildReferrersTag(desc); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + if (req.RequestUri?.AbsolutePath == $"/v2/test/manifests/{referrersTag}") + { + if (req.Headers.TryGetValues("Accept", out IEnumerable? values) && !values.Contains(MediaType.ImageIndex)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + } + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { expectedIndexDesc.Digest }); + return res; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + // no artifact type filtering + var fn1 = (IList referrers) => + { + Assert.Equivalent(expectedIndex.Manifests, referrers); + }; + var cancellationToken = new CancellationToken(); + var returnedReferrers1 = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, null, cancellationToken)) + { + returnedReferrers1.Add(referrer); + } + Assert.Equivalent(expectedIndex.Manifests, returnedReferrers1); + + // filter out referrers with artifact type doc/example + var artifactType2 = "doc/example"; + var returnedReferrers2 = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, artifactType2, cancellationToken)) + { + returnedReferrers2.Add(referrer); + } + Assert.Equal(3, returnedReferrers2.Count); + Assert.True(returnedReferrers2.All(referrer => referrer.ArtifactType == artifactType2)); + + // filter out non-existent referrers with artifact type non/non + var artifactType3 = "non/non"; + var returnedReferrers3 = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, artifactType3, cancellationToken)) + { + returnedReferrers3.Add(referrer); + } + Assert.Empty(returnedReferrers3); + } + + [Fact] + public async Task Repository_ReferrersAsync_ReferrersStateUnknown_ReferrersApiNotSupported() + { + var referrersList = new List + { + RandomDescriptor(artifactType: "doc/example"), + RandomDescriptor(artifactType: "doc/abc"), + RandomDescriptor(artifactType: "abc/abc"), + RandomDescriptor(artifactType: "abc/abc"), + RandomDescriptor(artifactType: "doc/example"), + RandomDescriptor(artifactType: "doc/example"), + }; + var expectedIndex = RandomIndex(referrersList); + var expectedIndexBytes = JsonSerializer.SerializeToUtf8Bytes(expectedIndex); + var desc = RandomDescriptor(); + var referrersTag = Referrers.BuildReferrersTag(desc); + var mockedHttpHandler = (HttpRequestMessage req, CancellationToken cancellationToken) => + { + var res = new HttpResponseMessage(); + res.RequestMessage = req; + if (req.Method != HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + } + + if (req.RequestUri?.AbsolutePath == $"/v2/test/referrers/{desc.Digest}") + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + if (req.RequestUri?.AbsolutePath == $"/v2/test/manifests/{referrersTag}") + { + if (req.Headers.TryGetValues("Accept", out IEnumerable? values) && + !values.Contains(MediaType.ImageIndex)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + } + + res.Content = new ByteArrayContent(expectedIndexBytes); + res.Content.Headers.Add("Content-Type", new string[] { MediaType.ImageIndex }); + res.Headers.Add(_dockerContentDigestHeader, new string[] { ComputeSHA256(expectedIndexBytes) }); + return res; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + var repo = new Repository(new RepositoryOptions() + { + Reference = Reference.Parse("localhost:5000/test"), + HttpClient = CustomClient(mockedHttpHandler), + PlainHttp = true, + }); + + var cancellationToken = new CancellationToken(); + Assert.Equal(Referrers.ReferrersState.Unknown, repo.ReferrersState); + var returnedReferrers = new List(); + await foreach (var referrer in repo.ReferrersAsync(desc, null, cancellationToken)) + { + returnedReferrers.Add(referrer); + } + + Assert.Equivalent(expectedIndex.Manifests, returnedReferrers); + Assert.Equal(Referrers.ReferrersState.NotSupported, repo.ReferrersState); + } + [Fact] public async Task PingReferrers_ShouldFailWhenReturnNotFound() { diff --git a/tests/OrasProject.Oras.Tests/Remote/UriFactoryTest.cs b/tests/OrasProject.Oras.Tests/Remote/UriFactoryTest.cs index 1fc7dd3..c85d5a5 100644 --- a/tests/OrasProject.Oras.Tests/Remote/UriFactoryTest.cs +++ b/tests/OrasProject.Oras.Tests/Remote/UriFactoryTest.cs @@ -27,7 +27,7 @@ public void BuildReferrersUrl_WithArtifactType_ShouldAddArtifactTypeToQueryStrin var reference = Reference.Parse("localhost:5000/test"); reference.ContentReference = desc.Digest; - + const string artifactType = "doc/example"; var expectedPath = $"referrers/{reference.ContentReference}"; const string expectedQuery = "artifactType=doc%2fexample"; @@ -41,8 +41,6 @@ public void BuildReferrersUrl_WithoutArtifactType() var desc = RandomDescriptor(); var reference = Reference.Parse("localhost:5000/test"); reference.ContentReference = desc.Digest; - - var expectedPath = $"referrers/{reference.ContentReference}"; var result = new UriFactory(reference).BuildReferrersUrl(); Assert.Equal($"https://localhost:5000/v2/test/{expectedPath}", result.ToString()); diff --git a/tests/OrasProject.Oras.Tests/Remote/Util/RandomDataGenerator.cs b/tests/OrasProject.Oras.Tests/Remote/Util/RandomDataGenerator.cs index f34df44..7415074 100644 --- a/tests/OrasProject.Oras.Tests/Remote/Util/RandomDataGenerator.cs +++ b/tests/OrasProject.Oras.Tests/Remote/Util/RandomDataGenerator.cs @@ -38,11 +38,11 @@ public static string RandomString() return new string(stringChars); } - public static Descriptor RandomDescriptor(string mediaType = MediaType.ImageManifest) + public static Descriptor RandomDescriptor(string mediaType = MediaType.ImageManifest, string artifactType = "") { var randomBytes = RandomBytes(); return new Descriptor - { MediaType = mediaType, Digest = Digest.ComputeSHA256(randomBytes), Size = randomBytes.Length }; + { MediaType = mediaType, Digest = Digest.ComputeSHA256(randomBytes), Size = randomBytes.Length, ArtifactType = artifactType }; } public static (Manifest, byte[]) RandomManifest() @@ -72,15 +72,20 @@ public static byte[] RandomBytes() return Encoding.UTF8.GetBytes(RandomString()); } - public static Index RandomIndex() + public static Index RandomIndex(IList? manifests = null) { - return new Index() + if (manifests == null) { - Manifests = new List + manifests = new List { RandomDescriptor(), RandomDescriptor(), - }, + RandomDescriptor(), + }; + } + return new Index() + { + Manifests = manifests, MediaType = MediaType.ImageIndex, }; }