From fabca21721458a10a46610356c50df2b6647baeb Mon Sep 17 00:00:00 2001 From: Valentin Delaye Date: Fri, 7 Mar 2025 18:47:41 +0100 Subject: [PATCH] Add layout ref to access descriptor on OCI layout Signed-off-by: Valentin Delaye --- .gitignore | 2 + .pre-commit-config.yaml | 1 + pom.xml | 4 +- src/main/java/land/oras/ContainerRef.java | 17 +--- src/main/java/land/oras/Index.java | 12 ++- src/main/java/land/oras/LayoutRef.java | 71 +++++++++++++ src/main/java/land/oras/Manifest.java | 10 ++ src/main/java/land/oras/OCI.java | 84 +++++++++++++++- src/main/java/land/oras/OCILayout.java | 99 ++++++++++++++++++- src/main/java/land/oras/Ref.java | 51 ++++++++++ src/main/java/land/oras/Registry.java | 66 ++----------- src/main/java/land/oras/utils/Const.java | 5 + .../land/oras/utils/SupportedAlgorithm.java | 9 ++ src/test/java/land/oras/LayoutRefTest.java | 65 ++++++++++++ src/test/java/land/oras/OCILayoutTest.java | 65 ++++++++++++ src/test/java/land/oras/RegistryTest.java | 3 +- ...16f7e8649e94fb4fc21fe77e8310c060f61caaff8a | 1 + ...9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 | 1 + ...3d66dddb112ecf1c2c52f47e6f45f3617bb2155d34 | 1 + src/test/resources/oci/artifact/index.json | 1 + src/test/resources/oci/artifact/oci-layout | 1 + .../resources/oci/empty-artifact/.GITKEEP | 0 22 files changed, 487 insertions(+), 82 deletions(-) create mode 100644 src/main/java/land/oras/LayoutRef.java create mode 100644 src/main/java/land/oras/Ref.java create mode 100644 src/test/java/land/oras/LayoutRefTest.java create mode 100644 src/test/resources/oci/artifact/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a create mode 100644 src/test/resources/oci/artifact/blobs/sha256/98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 create mode 100644 src/test/resources/oci/artifact/blobs/sha256/cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34 create mode 100644 src/test/resources/oci/artifact/index.json create mode 100644 src/test/resources/oci/artifact/oci-layout create mode 100644 src/test/resources/oci/empty-artifact/.GITKEEP diff --git a/.gitignore b/.gitignore index 978eeff2..e069c605 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ target/ # When testing JSON files *.json oci/ +!src/test/resources/oci/ +!src/test/resources/oci/**/*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40d2718c..b4e1e7e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,3 +4,4 @@ repos: hooks: - id: end-of-file-fixer - id: trailing-whitespace +exclude: ^src/test/resources/oci/artifact/ diff --git a/pom.xml b/pom.xml index 9e3caf8a..d0656ef0 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ land.oras oras-java-sdk - 0.2.1-SNAPSHOT + 0.3.0-SNAPSHOT jar ${project.groupId}:${project.artifactId} ORAS Java SDK @@ -253,7 +253,7 @@ 2024 false true - **/logback*.xml,**/junit-platform.properties + **/logback*.xml,**/junit-platform.properties,**/*.json = LICENSE = === = LICENSE END = diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index 1f815e48..f3287c43 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -34,7 +34,7 @@ * A referer of a container on a {@link Registry}. */ @NullMarked -public final class ContainerRef { +public final class ContainerRef extends Ref { /** * The regex pattern to parse the container name including the registry, namespace, repository, tag and digest. @@ -62,11 +62,6 @@ public final class ContainerRef { */ private final @Nullable String namespace; - /** - * The tag of the container. - */ - private final String tag; - /** * The digest of the container. */ @@ -82,10 +77,10 @@ public final class ContainerRef { */ private ContainerRef( String registry, @Nullable String namespace, String repository, String tag, @Nullable String digest) { + super(tag); this.registry = registry; this.namespace = namespace; this.repository = repository; - this.tag = tag; this.digest = digest; } @@ -129,14 +124,6 @@ public String getRepository() { return repository; } - /** - * Get the tag - * @return The tag - */ - public String getTag() { - return tag; - } - /** * Get the digest * @return The digest diff --git a/src/main/java/land/oras/Index.java b/src/main/java/land/oras/Index.java index 2ed6c018..f7f73406 100644 --- a/src/main/java/land/oras/Index.java +++ b/src/main/java/land/oras/Index.java @@ -20,6 +20,7 @@ package land.oras; +import java.nio.file.Path; import java.util.List; import land.oras.utils.Const; import land.oras.utils.JsonUtils; @@ -135,7 +136,7 @@ public String getJson() { } /** - * Create a manifest from a JSON string + * Create an index from a JSON string * @param json The JSON string * @return The index */ @@ -143,6 +144,15 @@ public static Index fromJson(String json) { return JsonUtils.fromJson(json, Index.class).withJson(json); } + /** + * Create an index from a path + * @param path The path + * @return The index + */ + public static Index fromPath(Path path) { + return JsonUtils.fromJson(path, Index.class); + } + /** * Create an index from a list of manifests * @param descriptors The list of manifests diff --git a/src/main/java/land/oras/LayoutRef.java b/src/main/java/land/oras/LayoutRef.java new file mode 100644 index 00000000..bd71293a --- /dev/null +++ b/src/main/java/land/oras/LayoutRef.java @@ -0,0 +1,71 @@ +/*- + * =LICENSE= + * ORAS Java SDK + * === + * Copyright (C) 2024 - 2025 ORAS + * === + * 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. + * =LICENSEEND= + */ + +package land.oras; + +import java.nio.file.Path; +import java.util.regex.Pattern; +import land.oras.exception.OrasException; +import org.jspecify.annotations.NullMarked; + +/** + * A referer of a container on a {@link OCILayout}. + */ +@NullMarked +public final class LayoutRef extends Ref { + + private final Path folder; + + private static final Pattern NAME_REGEX = Pattern.compile( + "^(.+?)(?::([^:@]+))?(?:@(.+))?$" // folder[:tag][@digest] + ); + + /** + * Private constructor + * @param tag The tag. + */ + private LayoutRef(Path folder, String tag) { + super(tag); + this.folder = folder; + } + + /** + * Get the folder + * @return The folder + */ + public Path getFolder() { + return folder; + } + + /** + * Parse the layout ref with folder and tag. + * @param name The layout ref. + * @return The container object with the registry, repository and tag. + */ + public static LayoutRef parse(String name) { + var matcher = NAME_REGEX.matcher(name); + if (!matcher.matches()) { + throw new OrasException("Invalid layout ref: " + name); + } + Path path = Path.of(matcher.group(1)); // Folder path + String tag = matcher.group(2) != null ? matcher.group(2) : matcher.group(3); // Tag or digest + return new LayoutRef(path, tag); + } +} diff --git a/src/main/java/land/oras/Manifest.java b/src/main/java/land/oras/Manifest.java index 898b7ed3..a879fff1 100644 --- a/src/main/java/land/oras/Manifest.java +++ b/src/main/java/land/oras/Manifest.java @@ -20,6 +20,7 @@ package land.oras; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; @@ -253,6 +254,15 @@ public static Manifest fromJson(String json) { return JsonUtils.fromJson(json, Manifest.class).withJson(json); } + /** + * Create a manifest from a path + * @param path The path + * @return The manifest + */ + public static Manifest fromPath(Path path) { + return JsonUtils.fromJson(path, Manifest.class); + } + /** * Return the original JSON * @return The original JSON diff --git a/src/main/java/land/oras/OCI.java b/src/main/java/land/oras/OCI.java index 991a7112..60e6457b 100644 --- a/src/main/java/land/oras/OCI.java +++ b/src/main/java/land/oras/OCI.java @@ -20,13 +20,93 @@ package land.oras; +import java.io.InputStream; +import java.nio.file.Path; +import org.jspecify.annotations.Nullable; + /** * Abstract class for OCI operation on remote registry or layout + * Commons methods for OCI operations + * @param The reference type */ -public abstract sealed class OCI permits Registry, OCILayout { +public abstract sealed class OCI permits Registry, OCILayout { /** * Default constructor */ - public OCI() {} + protected OCI() {} + + /** + * Push an artifact + * @param ref The ref + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact(T ref, LocalPath... paths) { + return pushArtifact(ref, ArtifactType.unknown(), Annotations.empty(), Config.empty(), paths); + } + + /** + * Push an artifact + * @param ref The ref + * @param artifactType The artifact type + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact(T ref, ArtifactType artifactType, LocalPath... paths) { + return pushArtifact(ref, artifactType, Annotations.empty(), Config.empty(), paths); + } + + /** + * Upload an ORAS artifact + * @param ref The ref + * @param artifactType The artifact type + * @param annotations The annotations + * @param paths The paths + * @return The manifest + */ + public Manifest pushArtifact(T ref, ArtifactType artifactType, Annotations annotations, LocalPath... paths) { + return pushArtifact(ref, artifactType, annotations, Config.empty(), paths); + } + + /** + * Push an artifact + * @param ref The container + * @param artifactType The artifact type. Can be null + * @param annotations The annotations + * @param config The config + * @param paths The paths + * @return The manifest + */ + public abstract Manifest pushArtifact( + T ref, ArtifactType artifactType, Annotations annotations, @Nullable Config config, LocalPath... paths); + + /** + * Pull an artifact + * @param ref The reference of the artifact + * @param path The path to save the artifact + * @param overwrite Overwrite the artifact if it exists + */ + public abstract void pullArtifact(T ref, Path path, boolean overwrite); + + /** + * Get the blob for the given digest. Not be suitable for large blobs + * @param ref The ref + * @return The blob as bytes + */ + public abstract byte[] getBlob(T ref); + + /** + * Fetch blob and save it to file + * @param ref The ref + * @param path The path to save the blob + */ + public abstract void fetchBlob(T ref, Path path); + + /** + * Fetch blob and return it as input stream + * @param ref The ref + * @return The input stream + */ + public abstract InputStream fetchBlob(T ref); } diff --git a/src/main/java/land/oras/OCILayout.java b/src/main/java/land/oras/OCILayout.java index e7a01df8..80f8e5fb 100644 --- a/src/main/java/land/oras/OCILayout.java +++ b/src/main/java/land/oras/OCILayout.java @@ -30,13 +30,14 @@ import land.oras.utils.Const; import land.oras.utils.JsonUtils; import land.oras.utils.SupportedAlgorithm; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Index from an OCI layout */ -public final class OCILayout extends OCI { +public final class OCILayout extends OCI { /** * The logger @@ -55,6 +56,70 @@ public final class OCILayout extends OCI { */ private OCILayout() {} + @Override + public Manifest pushArtifact( + LayoutRef ref, + ArtifactType artifactType, + Annotations annotations, + @Nullable Config config, + LocalPath... paths) { + throw new OrasException("Not implemented"); + } + + @Override + public void pullArtifact(LayoutRef ref, Path path, boolean overwrite) { + if (ref.getTag() == null) { + throw new OrasException("Tag is required to pull artifact from layout"); + } + + // Find manifest + Manifest manifest = findManifestByTag(ref); + + // Find the layer with title annotation + Layer layer = manifest.getLayers().stream() + .filter(l -> l.getAnnotations().containsKey(Const.ANNOTATION_TITLE)) + .findFirst() + .orElseThrow(() -> new OrasException("Layer not found with title annotation")); + + Path blobPath = getBlobPath(layer); + + // Copy the blob to the target path + try { + Files.copy(blobPath, path.resolve(layer.getAnnotations().get(Const.ANNOTATION_TITLE))); + } catch (IOException e) { + throw new OrasException("Failed to copy blob", e); + } + } + + @Override + public byte[] getBlob(LayoutRef containerRef) { + try (InputStream is = fetchBlob(containerRef)) { + return is.readAllBytes(); + } catch (IOException e) { + throw new OrasException("Failed to get blob", e); + } + } + + @Override + public void fetchBlob(LayoutRef ref, Path path) { + InputStream is = fetchBlob(ref); + try { + Files.copy(is, path); + } catch (IOException e) { + throw new OrasException("Failed to fetch blob", e); + } + } + + @Override + public InputStream fetchBlob(LayoutRef ref) { + Path blobPath = getBlobPath(ref); + try { + return Files.newInputStream(blobPath); + } catch (IOException e) { + throw new OrasException("Failed to fetch blob", e); + } + } + private void setPath(Path path) { this.path = path; } @@ -199,6 +264,38 @@ private Path getBlobPath() { return path.resolve(Const.OCI_LAYOUT_BLOBS); } + private Path getBlobPath(LayoutRef ref) { + if (ref.getTag() == null) { + throw new OrasException("Tag is required to get blob from layout"); + } + boolean isDigest = SupportedAlgorithm.isSupported(ref.getTag()); + if (isDigest) { + SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getTag()); + return getBlobPath().resolve(algorithm.getPrefix()).resolve(SupportedAlgorithm.getDigest(ref.getTag())); + } + + Manifest manifest = findManifestByTag(ref); + + return getBlobPath(manifest.getDescriptor()); + } + + private Manifest findManifestByTag(LayoutRef ref) { + String tag = ref.getTag(); + Index index = Index.fromPath(getIndexPath()); + ManifestDescriptor descriptor = index.getManifests().stream() + .filter(m -> tag != null && tag.equals(m.getAnnotations().get(Const.ANNOTATION_REF))) + .findFirst() + .orElseThrow(() -> new OrasException("Tag not found: %s".formatted(tag))); + + Path manifestPath = getBlobPath(descriptor); + if (!Files.exists(manifestPath)) { + throw new OrasException("Blob not found: %s".formatted(manifestPath)); + } + + // Read the manifest + return Manifest.fromPath(manifestPath).withDescriptor(descriptor); + } + private Path getBlobPath(ManifestDescriptor manifestDescriptor) { String digest = manifestDescriptor.getDigest(); SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(digest); diff --git a/src/main/java/land/oras/Ref.java b/src/main/java/land/oras/Ref.java new file mode 100644 index 00000000..fd4de210 --- /dev/null +++ b/src/main/java/land/oras/Ref.java @@ -0,0 +1,51 @@ +/*- + * =LICENSE= + * ORAS Java SDK + * === + * Copyright (C) 2024 - 2025 ORAS + * === + * 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. + * =LICENSEEND= + */ + +package land.oras; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A referer of a container on a {@link OCI}. + */ +@NullMarked +public abstract sealed class Ref permits ContainerRef, LayoutRef { + + /** + * The tag of the container. + */ + protected @Nullable final String tag; + + /** + * Default constructor + */ + protected Ref(String tag) { + this.tag = tag; + } + + /** + * Get the tag + * @return The tag + */ + public @Nullable String getTag() { + return tag; + } +} diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index ab2143f9..3b5d183f 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -52,7 +52,7 @@ * A registry is the main entry point for interacting with a container registry */ @NullMarked -public final class Registry extends OCI { +public final class Registry extends OCI { /** * The logger @@ -259,46 +259,7 @@ public void deleteBlob(ContainerRef containerRef) { handleError(response); } - /** - * Upload an ORAS artifact - * @param containerRef The container - * @param paths The paths - * @return The manifest - */ - public Manifest pushArtifact(ContainerRef containerRef, LocalPath... paths) { - return pushArtifact(containerRef, ArtifactType.unknown(), Annotations.empty(), Config.empty(), paths); - } - - /** - * Upload an ORAS artifact - * @param containerRef The container - * @param artifactType The artifact type - * @param paths The paths - * @return The manifest - */ - public Manifest pushArtifact(ContainerRef containerRef, ArtifactType artifactType, LocalPath... paths) { - return pushArtifact(containerRef, artifactType, Annotations.empty(), Config.empty(), paths); - } - - /** - * Upload an ORAS artifact - * @param containerRef The container - * @param artifactType The artifact type - * @param annotations The annotations - * @param paths The paths - * @return The manifest - */ - public Manifest pushArtifact( - ContainerRef containerRef, ArtifactType artifactType, Annotations annotations, LocalPath... paths) { - return pushArtifact(containerRef, artifactType, annotations, Config.empty(), paths); - } - - /** - * Download an ORAS artifact - * @param containerRef The container - * @param path The path - * @param overwrite Overwrite - */ + @Override public void pullArtifact(ContainerRef containerRef, Path path, boolean overwrite) { // Only collect layer that are files @@ -345,15 +306,7 @@ public void pullArtifact(ContainerRef containerRef, Path path, boolean overwrite } } - /** - * Upload an ORAS artifact - * @param containerRef The container - * @param artifactType The artifact type. Can be null - * @param annotations The annotations - * @param config The config - * @param paths The paths - * @return The manifest - */ + @Override public Manifest pushArtifact( ContainerRef containerRef, ArtifactType artifactType, @@ -603,6 +556,7 @@ public boolean hasBlob(ContainerRef containerRef) { * @param containerRef The container * @return The blob as bytes */ + @Override public byte[] getBlob(ContainerRef containerRef) { if (!hasBlob(containerRef)) { throw new OrasException(new OrasHttpClient.ResponseWrapper<>("", 404, Map.of())); @@ -621,11 +575,7 @@ public byte[] getBlob(ContainerRef containerRef) { return response.response().getBytes(); } - /** - * Fetch blob and save it to file - * @param containerRef The container - * @param path The path to save the blob - */ + @Override public void fetchBlob(ContainerRef containerRef, Path path) { if (!hasBlob(containerRef)) { throw new OrasException(new OrasHttpClient.ResponseWrapper<>("", 404, Map.of())); @@ -637,11 +587,7 @@ public void fetchBlob(ContainerRef containerRef, Path path) { handleError(response); } - /** - * Fetch blob and return it as input stream - * @param containerRef The container - * @return The input stream - */ + @Override public InputStream fetchBlob(ContainerRef containerRef) { if (!hasBlob(containerRef)) { throw new OrasException(new OrasHttpClient.ResponseWrapper<>("", 404, Map.of())); diff --git a/src/main/java/land/oras/utils/Const.java b/src/main/java/land/oras/utils/Const.java index b68e1b57..ec39b348 100644 --- a/src/main/java/land/oras/utils/Const.java +++ b/src/main/java/land/oras/utils/Const.java @@ -149,6 +149,11 @@ private Const() { */ public static final String ANNOTATION_CREATED = "org.opencontainers.image.created"; + /** + * Annotation for the ref name + */ + public static final String ANNOTATION_REF = "org.opencontainers.image.ref.name"; + /** * Get the current timestamp for the created annotation * @return The current timestamp diff --git a/src/main/java/land/oras/utils/SupportedAlgorithm.java b/src/main/java/land/oras/utils/SupportedAlgorithm.java index 4f6efca6..9a43a21b 100644 --- a/src/main/java/land/oras/utils/SupportedAlgorithm.java +++ b/src/main/java/land/oras/utils/SupportedAlgorithm.java @@ -105,6 +105,15 @@ public String digest(InputStream inputStream) { return DigestUtils.digest(algorithm, inputStream); } + /** + * Check if the algorithm is supported + * @param digest The digest + * @return True if supported + */ + public static boolean isSupported(String digest) { + return DIGEST_REGEX.matcher(digest).matches(); + } + /** * Get the algorithm from a digest * @param digest The digest diff --git a/src/test/java/land/oras/LayoutRefTest.java b/src/test/java/land/oras/LayoutRefTest.java new file mode 100644 index 00000000..b0f86055 --- /dev/null +++ b/src/test/java/land/oras/LayoutRefTest.java @@ -0,0 +1,65 @@ +/*- + * =LICENSE= + * ORAS Java SDK + * === + * Copyright (C) 2024 - 2025 ORAS + * === + * 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. + * =LICENSEEND= + */ + +package land.oras; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import land.oras.exception.OrasException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.CONCURRENT) +public class LayoutRefTest { + + @TempDir + public static Path tempDir; + + @Test + void shouldParseLayoutWithAllParts() { + String ociLayout = tempDir.resolve("foo").toString(); + LayoutRef layoutRef = LayoutRef.parse("%s:v1".formatted(ociLayout)); + assertEquals("v1", layoutRef.getTag()); + assertEquals(ociLayout, layoutRef.getFolder().toString()); + } + + @Test + void shouldParseLayoutWithDigest() { + String ociLayout = tempDir.resolve("foo").toString(); + LayoutRef layoutRef = LayoutRef.parse("%s@sha256:12345".formatted(ociLayout)); + assertEquals("sha256:12345", layoutRef.getTag()); + assertEquals(ociLayout, layoutRef.getFolder().toString()); + } + + @Test + void shouldParseFolderNameOnly() { + LayoutRef layoutRef = LayoutRef.parse("foo"); + assertNull(layoutRef.getTag()); + assertEquals("foo", layoutRef.getFolder().toString()); + } + + @Test + void shouldFailWithInvalidRef() { + assertThrows(OrasException.class, () -> LayoutRef.parse("")); + } +} diff --git a/src/test/java/land/oras/OCILayoutTest.java b/src/test/java/land/oras/OCILayoutTest.java index ed608cf9..bfd69d35 100644 --- a/src/test/java/land/oras/OCILayoutTest.java +++ b/src/test/java/land/oras/OCILayoutTest.java @@ -47,6 +47,12 @@ @Execution(ExecutionMode.CONCURRENT) public class OCILayoutTest { + @TempDir + private Path extractDir; + + @TempDir + private Path newArtifactsDir; + @TempDir private Path blobDir; @@ -59,6 +65,65 @@ public class OCILayoutTest { @Container private final ZotContainer registry = new ZotContainer().withStartupAttempts(3); + @Test + void shouldEnforceTagWhenPullArtifact() throws IOException { + LayoutRef layoutRef = LayoutRef.parse("src/test/resources/oci/artifact"); + OCILayout ociLayout = + OCILayout.Builder.builder().defaults(layoutRef.getFolder()).build(); + assertThrows(OrasException.class, () -> { + ociLayout.pullArtifact(layoutRef, extractDir, false); + }); + } + + @Test + void shouldPullFromOciLayout() throws IOException { + LayoutRef layoutRef = LayoutRef.parse("src/test/resources/oci/artifact:latest"); + OCILayout ociLayout = + OCILayout.Builder.builder().defaults(layoutRef.getFolder()).build(); + ociLayout.pullArtifact(layoutRef, extractDir, false); + + // Check file exists + assertTrue(Files.exists(extractDir.resolve("hi.txt"))); + + // Fetch the manifest + byte[] blob = ociLayout.getBlob(layoutRef); + Manifest manifest = Manifest.fromJson(new String(blob, StandardCharsets.UTF_8)); + assertEquals(1, manifest.getLayers().size()); + ociLayout.fetchBlob(layoutRef, extractDir.resolve("manifest.json")); + + // By digest + LayoutRef layoutRefDigest = LayoutRef.parse( + "src/test/resources/oci/artifact@sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4"); + ociLayout.fetchBlob(layoutRefDigest, extractDir.resolve("new_hi.txt")); + + // Ensure file exists + assertTrue(Files.exists(extractDir.resolve("manifest.json"))); + assertTrue(Files.exists(extractDir.resolve("new_hi.txt"))); + + // Assert content + assertEquals( + Files.readString( + Path.of( + "src/test/resources/oci/artifact/blobs/sha256/98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4")), + Files.readString(extractDir.resolve("new_hi.txt"))); + assertEquals( + Files.readString( + Path.of( + "src/test/resources/oci/artifact/blobs/sha256/cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34")), + Files.readString(extractDir.resolve("manifest.json"))); + } + + @Test + void shouldPushArtifact() throws IOException { + LayoutRef layoutRef = LayoutRef.parse("%s:foo".formatted(newArtifactsDir.toString())); + OCILayout ociLayout = + OCILayout.Builder.builder().defaults(newArtifactsDir).build(); + // Not implemented + assertThrows(OrasException.class, () -> { + ociLayout.pushArtifact(layoutRef, LocalPath.of(Path.of("test"))); + }); + } + @Test void testShouldCopyArtifactFromRegistryIntoOciLayout() throws IOException { diff --git a/src/test/java/land/oras/RegistryTest.java b/src/test/java/land/oras/RegistryTest.java index 2e626721..8d7abce6 100644 --- a/src/test/java/land/oras/RegistryTest.java +++ b/src/test/java/land/oras/RegistryTest.java @@ -602,7 +602,8 @@ void testShouldPushMinimalArtifactThenAttachArtifact() throws IOException { Files.writeString(pomFile, "my pom file"); // Push the main OCI artifact - registry.pushArtifact(containerRef, ArtifactType.from(artifactType), LocalPath.of(pomFile, "application/xml")); + assertNotNull(registry.pushArtifact( + containerRef, ArtifactType.from(artifactType), LocalPath.of(pomFile, "application/xml"))); // Create fake signature Path signedPomFile = blobDir.resolve("pom.xml.asc"); diff --git a/src/test/resources/oci/artifact/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a b/src/test/resources/oci/artifact/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/test/resources/oci/artifact/blobs/sha256/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/test/resources/oci/artifact/blobs/sha256/98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 b/src/test/resources/oci/artifact/blobs/sha256/98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 new file mode 100644 index 00000000..45b983be --- /dev/null +++ b/src/test/resources/oci/artifact/blobs/sha256/98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 @@ -0,0 +1 @@ +hi diff --git a/src/test/resources/oci/artifact/blobs/sha256/cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34 b/src/test/resources/oci/artifact/blobs/sha256/cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34 new file mode 100644 index 00000000..a5695f2e --- /dev/null +++ b/src/test/resources/oci/artifact/blobs/sha256/cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34 @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"foo/bar","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2,"data":"e30="},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4","size":3,"annotations":{"org.opencontainers.image.title":"hi.txt"}}],"annotations":{"org.opencontainers.image.created":"2025-03-08T08:20:56Z"}} \ No newline at end of file diff --git a/src/test/resources/oci/artifact/index.json b/src/test/resources/oci/artifact/index.json new file mode 100644 index 00000000..0eeee908 --- /dev/null +++ b/src/test/resources/oci/artifact/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:cb1d49baba271af2c56d493d66dddb112ecf1c2c52f47e6f45f3617bb2155d34","size":556,"annotations":{"org.opencontainers.image.created":"2025-03-08T08:20:56Z","org.opencontainers.image.ref.name":"latest"},"artifactType":"foo/bar"}]} \ No newline at end of file diff --git a/src/test/resources/oci/artifact/oci-layout b/src/test/resources/oci/artifact/oci-layout new file mode 100644 index 00000000..1343d370 --- /dev/null +++ b/src/test/resources/oci/artifact/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/src/test/resources/oci/empty-artifact/.GITKEEP b/src/test/resources/oci/empty-artifact/.GITKEEP new file mode 100644 index 00000000..e69de29b