diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index 65e21f13..ad1cc98c 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -225,6 +225,21 @@ public ContainerRef withTag(String tag) { return new ContainerRef(registry, unqualified, namespace, repository, tag, digest); } + /** + * Return a copy of this reference pointing at the tag used by the referrers tag schema, the + * fallback used by registries that do not implement the Referrers API. + * See Referrers Tag Schema + * @return The container reference for the referrers fallback tag + */ + public ContainerRef withReferrersFallbackTag() { + if (digest == null) { + throw new OrasException("Digest is required to compute the referrers fallback tag"); + } + String fallbackTag = "%s-%s" + .formatted(SupportedAlgorithm.fromDigest(digest).getPrefix(), SupportedAlgorithm.getDigest(digest)); + return new ContainerRef(registry, unqualified, namespace, repository, fallbackTag, null); + } + @Override public SupportedAlgorithm getAlgorithm() { // Default if not set diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index 0f3fab07..fdc9970e 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -617,6 +617,9 @@ public Referrers getReferrers(ContainerRef containerRef, @Nullable ArtifactType HttpClient.ResponseWrapper response = client.get( uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_INDEX_MEDIA_TYPE), Scopes.of(ref), authProvider); logResponse(response); + if (response.statusCode() == 404) { + return getReferrersFallback(ref, artifactType); + } handleError(response); Referrers page = JsonUtils.fromJson(response.response(), Referrers.class); String last = getLastParamFromLink(response); @@ -643,6 +646,37 @@ public Referrers getReferrers(ContainerRef containerRef, @Nullable ArtifactType return Referrers.from(allManifests); } + /** + * Fallback to the referrers tag schema when the Referrers API is unavailable (returns 404). + * See Unavailable Referrers API + * @param ref The container reference (with digest) to find referrers of + * @param artifactType The optional artifact type to filter on + * @return The referrers, or an empty list if the fallback tag does not exist or is not a valid index + */ + private Referrers getReferrersFallback(ContainerRef ref, @Nullable ArtifactType artifactType) { + ContainerRef fallbackRef = ref.withReferrersFallbackTag(); + URI uri = URI.create("%s://%s".formatted(getScheme(), fallbackRef.getManifestsPath(this))); + HttpClient.ResponseWrapper response = client.get( + uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_INDEX_MEDIA_TYPE), Scopes.of(fallbackRef), authProvider); + logResponse(response); + if (response.statusCode() >= 400) { + // No referrers tag either: assume there are no referrers for this digest + return Referrers.from(List.of()); + } + String contentType = response.headers().get(Const.CONTENT_TYPE_HEADER.toLowerCase()); + if (contentType == null || !isIndexMediaType(contentType)) { + // Not a valid image index: assume there are no referrers for this digest + return Referrers.from(List.of()); + } + List manifests = Index.fromJson(response.response()).getManifests(); + if (artifactType != null) { + manifests = manifests.stream() + .filter(manifest -> artifactType.getMediaType().equals(manifest.getArtifactType())) + .toList(); + } + return Referrers.from(manifests); + } + /** * Delete a manifest * @param containerRef The artifact diff --git a/src/test/java/land/oras/GitHubContainerRegistryITCase.java b/src/test/java/land/oras/GitHubContainerRegistryITCase.java index 23aad07d..da99aad3 100644 --- a/src/test/java/land/oras/GitHubContainerRegistryITCase.java +++ b/src/test/java/land/oras/GitHubContainerRegistryITCase.java @@ -20,6 +20,7 @@ package land.oras; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,7 +30,6 @@ import land.oras.utils.ArchiveUtils; import land.oras.utils.Const; import land.oras.utils.ZotUnsecureContainer; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Execution; @@ -65,7 +65,19 @@ void shouldPullIndex() { } @Test - @Disabled("Disabled because Referrers on GHCR") + void shouldGetReferrersUsingLegacyTagFallback() { + Registry registry = Registry.builder().build(); + ContainerRef containerRef = ContainerRef.parse( + "ghcr.io/jonesbusy/alpine-signed@sha256:9e56ed4cb843f61658fcdb17d4205a87d5e217515f23831314b2173a776174d6"); + Referrers referrers = registry.getReferrers(containerRef, null); + assertFalse(referrers.getManifests().isEmpty(), "Referrers must be found through the legacy tag fallback"); + assertTrue( + referrers.getManifests().stream().anyMatch(manifest -> "application/vnd.dev.sigstore.bundle.v0.3+json" + .equals(manifest.getArtifactType())), + "Sigstore bundle referrer must be found"); + } + + @Test @Execution(ExecutionMode.SAME_THREAD) void shouldPullSignedImage(@TempDir Path homeDir) throws Exception { diff --git a/src/test/java/land/oras/RegistryWireMockTest.java b/src/test/java/land/oras/RegistryWireMockTest.java index baedbd12..801b9b7e 100644 --- a/src/test/java/land/oras/RegistryWireMockTest.java +++ b/src/test/java/land/oras/RegistryWireMockTest.java @@ -1765,4 +1765,112 @@ void shouldFailOnReferrerListPaginationExceedingMaxPages(WireMockRuntimeInfo wmR exception.getMessage().contains("Referrer listing exceeded 2 pages"), "Unexpected message: " + exception.getMessage()); } + + @Test + void shouldFallbackToReferrersTagSchemaWhenReferrersApiUnavailable(WireMockRuntimeInfo wmRuntimeInfo) { + + String digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + String referrersPath = "/v2/library/artifact-text/referrers/" + digest; + String fallbackTagPath = "/v2/library/artifact-text/manifests/" + + "sha256-44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + + String fallbackIndex = + """ + {"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":\ + [{"mediaType":"application/vnd.oci.image.manifest.v1+json",\ + "digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,\ + "artifactType":"application/vnd.example+json"}]}"""; + + WireMock wireMock = wmRuntimeInfo.getWireMock(); + // Registry does not implement the Referrers API (e.g. GHCR) + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(referrersPath)).willReturn(WireMock.notFound())); + // Fallback to the referrers tag schema: + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(fallbackTagPath)) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/vnd.oci.image.index.v1+json") + .withBody(fallbackIndex))); + + Registry registry = Registry.Builder.builder() + .withAuthProvider(authProvider) + .withInsecure(true) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/artifact-text" + .formatted(wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""))) + .withDigest(digest); + + Referrers referrers = registry.getReferrers(ref, null); + assertEquals(1, referrers.getManifests().size()); + assertEquals( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + referrers.getManifests().get(0).getDigest()); + } + + @Test + void shouldFilterFallbackReferrersByArtifactType(WireMockRuntimeInfo wmRuntimeInfo) { + + String digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + String referrersPath = "/v2/library/artifact-text/referrers/" + digest; + String fallbackTagPath = "/v2/library/artifact-text/manifests/" + + "sha256-44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + + String fallbackIndex = + """ + {"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":\ + [{"mediaType":"application/vnd.oci.image.manifest.v1+json",\ + "digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,\ + "artifactType":"application/vnd.example+json"},\ + {"mediaType":"application/vnd.oci.image.manifest.v1+json",\ + "digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":1,\ + "artifactType":"application/vnd.other+json"}]}"""; + + WireMock wireMock = wmRuntimeInfo.getWireMock(); + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(referrersPath)).willReturn(WireMock.notFound())); + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(fallbackTagPath)) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/vnd.oci.image.index.v1+json") + .withBody(fallbackIndex))); + + Registry registry = Registry.Builder.builder() + .withAuthProvider(authProvider) + .withInsecure(true) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/artifact-text" + .formatted(wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""))) + .withDigest(digest); + + Referrers referrers = registry.getReferrers(ref, ArtifactType.from("application/vnd.example+json")); + assertEquals(1, referrers.getManifests().size()); + assertEquals( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + referrers.getManifests().get(0).getDigest()); + } + + @Test + void shouldReturnEmptyReferrersWhenFallbackTagDoesNotExist(WireMockRuntimeInfo wmRuntimeInfo) { + + String digest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + String referrersPath = "/v2/library/artifact-text/referrers/" + digest; + String fallbackTagPath = "/v2/library/artifact-text/manifests/" + + "sha256-44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + + WireMock wireMock = wmRuntimeInfo.getWireMock(); + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(referrersPath)).willReturn(WireMock.notFound())); + wireMock.register(WireMock.get(WireMock.urlPathEqualTo(fallbackTagPath)).willReturn(WireMock.notFound())); + + Registry registry = Registry.Builder.builder() + .withAuthProvider(authProvider) + .withInsecure(true) + .build(); + + ContainerRef ref = ContainerRef.parse("%s/library/artifact-text" + .formatted(wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""))) + .withDigest(digest); + + Referrers referrers = registry.getReferrers(ref, null); + assertTrue(referrers.getManifests().isEmpty()); + } }