diff --git a/src/main/java/land/oras/Annotations.java b/src/main/java/land/oras/Annotations.java index 6ca09eb4..a948d47b 100644 --- a/src/main/java/land/oras/Annotations.java +++ b/src/main/java/land/oras/Annotations.java @@ -25,6 +25,7 @@ import java.util.stream.Collectors; import land.oras.utils.JsonUtils; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Record for annotations @@ -44,7 +45,10 @@ public record Annotations( * @param manifestAnnotations The manifest annotations * @return The annotations */ - public static Annotations ofManifest(Map manifestAnnotations) { + public static Annotations ofManifest(@Nullable Map manifestAnnotations) { + if (manifestAnnotations == null) { + return empty(); + } return new Annotations(new HashMap<>(), manifestAnnotations, new HashMap<>()); } diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index c4e103f2..1e0d9f5c 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -114,6 +114,10 @@ public String getApiRegistry() { * @return The namespace */ public @Nullable String getNamespace() { + String registry = getRegistry(); + if (namespace == null && registry.equals("docker.io")) { + return "library"; + } return namespace; } @@ -147,7 +151,7 @@ public String getTag() { * @return The new container reference */ public ContainerRef withDigest(String digest) { - return new ContainerRef(registry, namespace, repository, tag, digest); + return new ContainerRef(registry, getNamespace(), repository, tag, digest); } /** @@ -169,9 +173,9 @@ public SupportedAlgorithm getAlgorithm() { */ private String getApiPrefix() { if (namespace != null) { - return "%s/v2/%s/%s".formatted(registry, namespace, repository); + return "%s/v2/%s/%s".formatted(getApiRegistry(), getNamespace(), repository); } - return "%s/v2/%s".formatted(registry, repository); + return "%s/v2/%s".formatted(getApiRegistry(), repository); } /** diff --git a/src/main/java/land/oras/Manifest.java b/src/main/java/land/oras/Manifest.java index 7694145f..b2d8f19c 100644 --- a/src/main/java/land/oras/Manifest.java +++ b/src/main/java/land/oras/Manifest.java @@ -46,7 +46,7 @@ public final class Manifest { /** * The manifest descriptor */ - private transient ManifestDescriptor descriptor; + private final transient ManifestDescriptor descriptor; private Manifest( int schemaVersion, @@ -127,7 +127,7 @@ public Subject getSubject() { * @return The layers */ public List getLayers() { - return Collections.unmodifiableList(layers); + return layers != null ? Collections.unmodifiableList(layers) : List.of(); } /** diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index e7900cd2..8c0a3328 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -397,20 +397,22 @@ public void copy(ContainerRef containerRef, Path folder) { Files.writeString(indexFile, index.toJson()); // Write config as any blob - String configDigest = sourceConfig.getDigest(); - SupportedAlgorithm configAlgorithm = SupportedAlgorithm.fromDigest(configDigest); - Path configFile = blobs.resolve(configAlgorithm.getPrefix()) - .resolve(SupportedAlgorithm.getDigest(sourceConfig.getDigest())); - Path configPrefixDirectory = blobs.resolve(configAlgorithm.getPrefix()); - if (!Files.exists(configPrefixDirectory)) { - Files.createDirectory(configPrefixDirectory); - } - // Write the data from data or fetch the blob - if (sourceConfig.getData() != null) { - Files.write(configFile, sourceConfig.getDataBytes()); - } else { - try (InputStream is = fetchBlob(containerRef.withDigest(configDigest))) { - Files.copy(is, configFile); + if (sourceConfig != null) { + String configDigest = sourceConfig.getDigest(); + SupportedAlgorithm configAlgorithm = SupportedAlgorithm.fromDigest(configDigest); + Path configFile = blobs.resolve(configAlgorithm.getPrefix()) + .resolve(SupportedAlgorithm.getDigest(sourceConfig.getDigest())); + Path configPrefixDirectory = blobs.resolve(configAlgorithm.getPrefix()); + if (!Files.exists(configPrefixDirectory)) { + Files.createDirectory(configPrefixDirectory); + } + // Write the data from data or fetch the blob + if (sourceConfig.getData() != null) { + Files.write(configFile, sourceConfig.getDataBytes()); + } else { + try (InputStream is = fetchBlob(containerRef.withDigest(configDigest))) { + Files.copy(is, configFile); + } } } @@ -737,8 +739,9 @@ public Manifest getManifest(ContainerRef containerRef) { String contentType = response.headers().get(Const.CONTENT_TYPE_HEADER.toLowerCase()); String size = response.headers().get(Const.CONTENT_LENGTH_HEADER.toLowerCase()); String digest = response.headers().get(Const.DOCKER_CONTENT_DIGEST_HEADER.toLowerCase()); - return JsonUtils.fromJson(response.response(), Manifest.class) - .withDescriptor(ManifestDescriptor.of(contentType, digest, size == null ? 0 : Long.parseLong(size))); + ManifestDescriptor descriptor = + ManifestDescriptor.of(contentType, digest, size == null ? 0 : Long.parseLong(size)); + return JsonUtils.fromJson(response.response(), Manifest.class).withDescriptor(descriptor); } /** diff --git a/src/test/java/land/oras/AnnotationsTest.java b/src/test/java/land/oras/AnnotationsTest.java index 4eafc6b7..cd9fed1e 100644 --- a/src/test/java/land/oras/AnnotationsTest.java +++ b/src/test/java/land/oras/AnnotationsTest.java @@ -43,6 +43,14 @@ public void fromJson() { assertEquals("more cream", annotations.getFileAnnotations("cake.txt").get("fun")); } + @Test + public void nullAnnotations() { + Annotations annotations = Annotations.ofManifest(null); + assertEquals(0, annotations.configAnnotations().size()); + assertEquals(0, annotations.manifestAnnotations().size()); + assertEquals(0, annotations.filesAnnotations().size()); + } + @Test public void toJson() { Annotations annotations = new Annotations( diff --git a/src/test/java/land/oras/ContainerRefTest.java b/src/test/java/land/oras/ContainerRefTest.java index c5204a57..714554a8 100644 --- a/src/test/java/land/oras/ContainerRefTest.java +++ b/src/test/java/land/oras/ContainerRefTest.java @@ -33,12 +33,11 @@ public class ContainerRefTest { @Test void shouldParseImageWithAllParts() { - ContainerRef containerRef = - ContainerRef.parse("docker.io/library/foo/hello-world:latest@sha256:1234567890abcdef"); + ContainerRef containerRef = ContainerRef.parse("docker.io/library/foo/alpine:latest@sha256:1234567890abcdef"); assertEquals("docker.io", containerRef.getRegistry()); assertEquals("registry-1.docker.io", containerRef.getApiRegistry()); assertEquals("library/foo", containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertEquals(SupportedAlgorithm.SHA256, containerRef.getAlgorithm()); assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); @@ -48,66 +47,97 @@ void shouldParseImageWithAllParts() { void shouldFailWithUnSupportedAlgorithm() { assertThrows( OrasException.class, - () -> ContainerRef.parse("docker.io/library/foo/hello-world:latest@test:1234567890abcdef"), + () -> ContainerRef.parse("docker.io/library/foo/alpine:latest@test:1234567890abcdef"), "Unsupported algorithm: test"); } @Test void shouldParseImageWithNoNamespace() { - ContainerRef containerRef = ContainerRef.parse("docker.io/hello-world:latest@sha256:1234567890abcdef"); + ContainerRef containerRef = ContainerRef.parse("docker.io/alpine:latest@sha256:1234567890abcdef"); assertEquals("docker.io", containerRef.getRegistry()); + assertEquals("library", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); + assertEquals("latest", containerRef.getTag()); + assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); + + containerRef = ContainerRef.parse("demo.goharbor.com/alpine:latest@sha256:1234567890abcdef"); + assertEquals("demo.goharbor.com", containerRef.getRegistry()); + assertEquals("demo.goharbor.com/v2/alpine/tags/list", containerRef.getTagsPath()); assertNull(containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); } @Test void shouldParseImageWithNoTag() { - ContainerRef containerRef = ContainerRef.parse("docker.io/hello-world@sha256:1234567890abcdef"); + ContainerRef containerRef = ContainerRef.parse("docker.io/alpine@sha256:1234567890abcdef"); assertEquals("docker.io", containerRef.getRegistry()); - assertNull(containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("registry-1.docker.io/v2/alpine/tags/list", containerRef.getTagsPath()); + assertEquals("library", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); + assertEquals("latest", containerRef.getTag()); + assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); + + containerRef = ContainerRef.parse("docker.io/foobar/alpine@sha256:1234567890abcdef"); + assertEquals("registry-1.docker.io/v2/foobar/alpine/tags/list", containerRef.getTagsPath()); + assertEquals("docker.io", containerRef.getRegistry()); + assertEquals("foobar", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertEquals("sha256:1234567890abcdef", containerRef.getDigest()); } @Test void shouldParseImageWithNoDigest() { - ContainerRef containerRef = ContainerRef.parse("docker.io/hello-world:latest"); + ContainerRef containerRef = ContainerRef.parse("docker.io/alpine:latest"); assertEquals("docker.io", containerRef.getRegistry()); - assertNull(containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("library", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertNull(containerRef.getDigest()); } @Test void shouldParseImageWithNoRegistry() { - ContainerRef containerRef = ContainerRef.parse("hello-world:latest"); + ContainerRef containerRef = ContainerRef.parse("alpine:latest"); assertEquals("docker.io", containerRef.getRegistry()); assertEquals("registry-1.docker.io", containerRef.getApiRegistry()); - assertNull(containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("registry-1.docker.io/v2/alpine/tags/list", containerRef.getTagsPath()); + assertEquals("library", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertNull(containerRef.getDigest()); } @Test void shouldParseImageWithNoTagAndNoRegistry() { - ContainerRef containerRef = ContainerRef.parse("hello-world"); + ContainerRef containerRef = ContainerRef.parse("alpine"); assertEquals("docker.io", containerRef.getRegistry()); - assertNull(containerRef.getNamespace()); - assertEquals("hello-world", containerRef.getRepository()); + assertEquals("library", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); assertEquals("latest", containerRef.getTag()); assertNull(containerRef.getDigest()); + + containerRef = ContainerRef.parse("foobar/alpine"); + assertEquals("docker.io", containerRef.getRegistry()); + assertEquals("foobar", containerRef.getNamespace()); + assertEquals("alpine", containerRef.getRepository()); + assertEquals("latest", containerRef.getTag()); + assertNull(containerRef.getDigest()); + } + + @Test + void shouldGetTagsPathDockerIo() { + ContainerRef containerRef = ContainerRef.parse("docker.io/library/foo/alpine:latest@sha256:1234567890abcdef"); + assertEquals("registry-1.docker.io/v2/library/foo/alpine/tags/list", containerRef.getTagsPath()); } @Test - void shouldGetTagsPath() { + void shouldGetTagsPathOtherRegistry() { ContainerRef containerRef = - ContainerRef.parse("docker.io/library/foo/hello-world:latest@sha256:1234567890abcdef"); - assertEquals("docker.io/v2/library/foo/hello-world/tags/list", containerRef.getTagsPath()); + ContainerRef.parse("demo.goharbor.io/library/foo/alpine:latest@sha256:1234567890abcdef"); + assertEquals("demo.goharbor.io/v2/library/foo/alpine/tags/list", containerRef.getTagsPath()); } @Test diff --git a/src/test/java/land/oras/ManifestTest.java b/src/test/java/land/oras/ManifestTest.java index f4c7a1df..f2297735 100644 --- a/src/test/java/land/oras/ManifestTest.java +++ b/src/test/java/land/oras/ManifestTest.java @@ -77,6 +77,14 @@ void shouldHaveEmptyManifest() { Manifest.fromJson(emptyManifest()).toJson(), Manifest.empty().toJson()); } + @Test + void shouldHaveNoLayerForIndex() { + String json = + "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.oci.image.index.v1+json\",\"annotations\":{}}"; + Manifest manifest = Manifest.fromJson(json); + assertEquals(0, manifest.getLayers().size()); + } + @Test void shouldGetArtifactTest() { Manifest manifest1 = Manifest.empty().withArtifactType(ArtifactType.from("test/plain"));