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());
+ }
}