diff --git a/src/main/java/land/oras/CopyUtils.java b/src/main/java/land/oras/CopyUtils.java index 419aac75..1229a943 100644 --- a/src/main/java/land/oras/CopyUtils.java +++ b/src/main/java/land/oras/CopyUtils.java @@ -20,10 +20,13 @@ * =LICENSEEND= */ +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import land.oras.exception.OrasException; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,15 +50,18 @@ private CopyUtils() { /** * Options for copy. * @param includeReferrers Whether to include referrers in the copy + * @param platformFilter Optional platform filter. When set on an index copy, only manifests matching + * one of the given platforms are copied and the resulting index contains only + * those manifests. The resulting index digest will differ from the source index. */ - public record CopyOptions(boolean includeReferrers) { + public record CopyOptions(boolean includeReferrers, @Nullable Set platformFilter) { /** * The default copy options with includeReferrers to false * @return The default copy options */ public static CopyOptions shallow() { - return new CopyOptions(false); + return new CopyOptions(false, null); } /** @@ -63,7 +69,18 @@ public static CopyOptions shallow() { * @return The copy options with includeReferrers and recursive set to true */ public static CopyOptions deep() { - return new CopyOptions(true); + return new CopyOptions(true, null); + } + + /** + * Return a new CopyOptions with the given platform filter. + * When copying an index, only manifests matching one of the given platforms will be copied. + * The resulting index will contain only the filtered manifests and will have a different digest. + * @param platforms The platforms to filter by + * @return New CopyOptions with the platform filter set + */ + public CopyOptions withPlatformFilter(Set platforms) { + return new CopyOptions(includeReferrers, platforms); } } @@ -220,8 +237,32 @@ else if (source.isIndexMediaType(contentType)) { Index index = source.getIndex(effectiveSourceRef); String targetTag = effectiveTargetRef.getTag(); + // Apply platform filter if set — partial copy produces a new index with fewer manifests + List manifestsToCopy; + Index indexToPush; + if (options.platformFilter() != null) { + List filtered = index.getManifests().stream() + .filter(d -> + options.platformFilter().stream().anyMatch(p -> Platform.matches(d.getPlatform(), p))) + .toList(); + if (filtered.isEmpty()) { + throw new OrasException( + "No manifests found in index matching platform filter: " + options.platformFilter()); + } + manifestsToCopy = filtered; + indexToPush = index.withManifests(filtered); + LOG.debug( + "Platform filter {} matched {}/{} manifests", + options.platformFilter(), + filtered.size(), + index.getManifests().size()); + } else { + manifestsToCopy = index.getManifests(); + indexToPush = index; + } + // Write all manifests and their config - for (ManifestDescriptor manifestDescriptor : index.getManifests()) { + for (ManifestDescriptor manifestDescriptor : manifestsToCopy) { // Copy manifest if (source.isManifestMediaType(manifestDescriptor.getMediaType())) { @@ -260,7 +301,7 @@ else if (source.isIndexMediaType(contentType)) { } LOG.debug("Copying index {}", manifestDigest); - Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), index); + Index pushedIndex = target.pushIndex(effectiveTargetRef.withDigest(targetTag), indexToPush); LOG.debug("Copied index {} with tag {}", pushedIndex, targetTag); } else { diff --git a/src/main/java/land/oras/Index.java b/src/main/java/land/oras/Index.java index 42d1a39f..2bb3c67f 100644 --- a/src/main/java/land/oras/Index.java +++ b/src/main/java/land/oras/Index.java @@ -336,6 +336,16 @@ public Subject getSubject() { return subject; } + /** + * Return a new index with the given manifests + * @param manifests The manifests + * @return The index + */ + public Index withManifests(List manifests) { + return new Index( + schemaVersion, mediaType, artifactType, manifests, annotations, subject, descriptor, registry, null); + } + /** * Return a new index with the given subject * @param subject The subject diff --git a/src/test/java/land/oras/OCILayoutTest.java b/src/test/java/land/oras/OCILayoutTest.java index 2a0fab16..8c67ac4a 100644 --- a/src/test/java/land/oras/OCILayoutTest.java +++ b/src/test/java/land/oras/OCILayoutTest.java @@ -29,6 +29,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Set; import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.SupportedAlgorithm; @@ -780,6 +781,85 @@ void testShouldCopyArtifactFromRegistryIntoOciLayout() throws IOException { assertBlobAbsent(layoutPath, SupportedAlgorithm.SHA256.digest(file2)); } + @Test + void testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout() throws IOException { + + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + + Path ociLayoutPath = layoutPath.resolve("testShouldCopyIndexWithPlatformFilterFromRegistryIntoOciLayout"); + OCILayout ociLayout = + OCILayout.Builder.builder().defaults(ociLayoutPath).build(); + LayoutRef layoutRef = LayoutRef.parse("%s:latest".formatted(ociLayoutPath.toString())); + + ContainerRef containerRef = + ContainerRef.parse("%s/library/platform-filter-source".formatted(this.registry.getRegistry())); + + // Push two manifests with different content, one per platform + Path fileAmd64 = blobDir.resolve("platform-filter-amd64.txt"); + Path fileArm64 = blobDir.resolve("platform-filter-arm64.txt"); + Files.writeString(fileAmd64, "content-amd64"); + Files.writeString(fileArm64, "content-arm64"); + + Manifest manifestAmd64 = registry.pushArtifact(containerRef.withTag("amd64"), LocalPath.of(fileAmd64)); + Manifest manifestArm64 = registry.pushArtifact(containerRef.withTag("arm64"), LocalPath.of(fileArm64)); + + assertNotNull(manifestAmd64.getDescriptor()); + assertNotNull(manifestArm64.getDescriptor()); + + // Build a multi-platform index and push it + ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64()); + ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8()); + Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64)); + registry.pushIndex(containerRef.withTag("latest"), sourceIndex); + + // Copy only linux/amd64 into the OCI layout + CopyUtils.copy( + registry, + containerRef.withTag("latest"), + ociLayout, + layoutRef, + CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64()))); + + // The layout must be a valid OCI layout + assertOciLayout(ociLayoutPath); + + // The OCI layout root index.json has two entries: + // 1. the amd64 manifest blob (pushed individually, no tag) + // 2. the filtered index blob (tagged "latest") + Index ociIndex = Index.fromPath(ociLayoutPath.resolve(Const.OCI_LAYOUT_INDEX)); + assertEquals(2, ociIndex.getManifests().size(), "OCI layout index.json must have two entries"); + + // Find the filtered-index entry by its tag annotation + ManifestDescriptor filteredIndexDescriptor = ociIndex.getManifests().stream() + .filter(d -> d.getAnnotations() != null + && "latest".equals(d.getAnnotations().get(Const.ANNOTATION_REF))) + .findFirst() + .orElseThrow(() -> new AssertionError("No entry with tag 'latest' found in OCI layout index.json")); + assertBlobExists(ociLayoutPath, filteredIndexDescriptor.getDigest()); + + // The filtered index blob itself contains only the amd64 manifest descriptor + Index filteredIndex = Index.fromJson(Files.readString(ociLayoutPath + .resolve("blobs") + .resolve("sha256") + .resolve(SupportedAlgorithm.getDigest(filteredIndexDescriptor.getDigest())))); + assertEquals(1, filteredIndex.getManifests().size(), "Filtered index must contain exactly one manifest"); + assertEquals( + Platform.linuxAmd64(), + filteredIndex.getManifests().get(0).getPlatform(), + "The single manifest in the filtered index must be linux/amd64"); + + // The amd64 layer blob and its config must be present + assertBlobExists(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64)); + assertBlobContent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileAmd64), "content-amd64"); + assertBlobContent(ociLayoutPath, Config.empty().getDigest(), "{}"); + + // The arm64 layer blob must be absent — it was filtered out + assertBlobAbsent(ociLayoutPath, SupportedAlgorithm.SHA256.digest(fileArm64)); + } + @Test void testShouldCopyFromOciLayoutIntoOciLayoutRecursive() throws IOException { diff --git a/src/test/java/land/oras/RegistryTest.java b/src/test/java/land/oras/RegistryTest.java index 0e009792..f6d55180 100644 --- a/src/test/java/land/oras/RegistryTest.java +++ b/src/test/java/land/oras/RegistryTest.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import land.oras.exception.OrasException; @@ -2275,4 +2276,96 @@ void shouldPushBlobChunkedFromStreamViaInsecureRegistryConfig(@TempDir Path home registry.deleteBlob(containerRef.withDigest(expectedDigest)); }); } + + @Test + void testShouldCopyIndexWithPlatformFilter() throws IOException { + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + + ContainerRef containerSource = + ContainerRef.parse("%s/library/multi-platform-source".formatted(this.registry.getRegistry())); + + // Push two manifests with different content + Path fileAmd64 = blobDir.resolve("amd64.txt"); + Files.writeString(fileAmd64, "content-amd64"); + Path fileArm64 = blobDir.resolve("arm64.txt"); + Files.writeString(fileArm64, "content-arm64"); + + Manifest manifestAmd64 = registry.pushArtifact(containerSource.withTag("amd64"), LocalPath.of(fileAmd64)); + Manifest manifestArm64 = registry.pushArtifact(containerSource.withTag("arm64"), LocalPath.of(fileArm64)); + + assertNotNull(manifestAmd64.getDescriptor()); + assertNotNull(manifestArm64.getDescriptor()); + + // Build a multi-platform index + ManifestDescriptor descAmd64 = manifestAmd64.getDescriptor().withPlatform(Platform.linuxAmd64()); + ManifestDescriptor descArm64 = manifestArm64.getDescriptor().withPlatform(Platform.linuxArm64V8()); + + Index sourceIndex = Index.fromManifests(List.of(descAmd64, descArm64)); + registry.pushIndex(containerSource.withTag("latest"), sourceIndex); + + // Copy only linux/amd64 to target + ContainerRef containerTarget = + ContainerRef.parse("%s/library/multi-platform-target:latest".formatted(this.registry.getRegistry())); + CopyUtils.copy( + registry, + containerSource.withTag("latest"), + registry, + containerTarget, + CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxAmd64()))); + + // Verify the target index only contains the amd64 manifest + Index targetIndex = registry.getIndex(containerTarget); + assertEquals(1, targetIndex.getManifests().size(), "Filtered index should contain exactly one manifest"); + assertEquals( + Platform.linuxAmd64(), + targetIndex.getManifests().get(0).getPlatform(), + "The single manifest should be linux/amd64"); + + // The target index digest must differ from the source index digest + assertNotEquals( + sourceIndex.getDescriptor() != null + ? sourceIndex.getDescriptor().getDigest() + : null, + targetIndex.getDescriptor() != null + ? targetIndex.getDescriptor().getDigest() + : null, + "Partial copy must produce a different index digest"); + } + + @Test + void testShouldThrowWhenPlatformFilterMatchesNothing() throws IOException { + Registry registry = Registry.Builder.builder() + .defaults("myuser", "mypass") + .withInsecure(true) + .build(); + + ContainerRef containerSource = + ContainerRef.parse("%s/library/no-match-source".formatted(this.registry.getRegistry())); + + // Push a single manifest and build an index with linux/amd64 + Path file = blobDir.resolve("no-match.txt"); + Files.writeString(file, "no-match"); + Manifest manifest = registry.pushArtifact(containerSource.withTag("v1"), LocalPath.of(file)); + assertNotNull(manifest.getDescriptor()); + + Index sourceIndex = Index.fromManifests(List.of(manifest.getDescriptor().withPlatform(Platform.linuxAmd64()))); + registry.pushIndex(containerSource.withTag("latest"), sourceIndex); + + ContainerRef containerTarget = + ContainerRef.parse("%s/library/no-match-target:latest".formatted(this.registry.getRegistry())); + + // Filtering for a platform not present in the index must throw + assertThrows( + OrasException.class, + () -> CopyUtils.copy( + registry, + containerSource.withTag("latest"), + registry, + containerTarget, + CopyUtils.CopyOptions.shallow().withPlatformFilter(Set.of(Platform.linuxArm64V8()))), + "Copy with non-matching platform filter should throw OrasException"); + } }