Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>land.oras</groupId>
<artifactId>oras-java-sdk</artifactId>
<version>0.3.0-SNAPSHOT</version>
<version>0.2.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>${project.groupId}:${project.artifactId}</name>
<description>ORAS Java SDK</description>
Expand Down
34 changes: 20 additions & 14 deletions src/main/java/land/oras/OCILayout.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public void fetchBlob(LayoutRef ref, Path path) {
InputStream is = fetchBlob(ref);
try {
Files.copy(is, path);
LOG.info("Downloaded: {}", ref.getTag());
} catch (IOException e) {
throw new OrasException("Failed to fetch blob", e);
}
Expand All @@ -115,14 +116,14 @@ public InputStream fetchBlob(LayoutRef ref) {

@Override
public Layer pushBlob(LayoutRef ref, Path blob, Map<String, String> annotations) {
ensureDigest(ref);
ensureDigest(ref, blob);
Path blobPath = getBlobPath(ref);
String digest = ref.getAlgorithm().digest(blob);
ensureAlgorithmPath(digest);
LOG.debug("Digest: {}", digest);
try {
if (Files.exists(blobPath)) {
LOG.debug("Blob already exists: {}", blobPath);
LOG.info("Blob already exists: {}", digest);
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
}
Files.copy(blob, blobPath);
Expand All @@ -134,12 +135,12 @@ public Layer pushBlob(LayoutRef ref, Path blob, Map<String, String> annotations)

@Override
public Layer pushBlob(LayoutRef ref, byte[] data) {
ensureDigest(ref);
String digest = ref.getAlgorithm().digest(data);
ensureAlgorithmPath(digest);
try {
Path path = Files.createTempFile("oras", "blob");
Files.write(path, data);
ensureDigest(ref, path);
String digest = ref.getAlgorithm().digest(data);
ensureAlgorithmPath(digest);
return pushBlob(ref, path, Map.of());
} catch (IOException e) {
throw new OrasException("Failed to push blob to OCI layout", e);
Expand Down Expand Up @@ -296,15 +297,6 @@ else if (registry.isIndexMediaType(contentType)) {
}
}

private void ensureDigest(LayoutRef ref) {
if (ref.getTag() == null) {
throw new OrasException("Missing ref");
}
if (!SupportedAlgorithm.isSupported(ref.getTag())) {
throw new OrasException("Unsupported digest: %s".formatted(ref.getTag()));
}
}

private Path getOciLayoutPath() {
return path.resolve(Const.OCI_LAYOUT_FILE);
}
Expand Down Expand Up @@ -431,6 +423,20 @@ private void writeConfig(Registry registry, ContainerRef containerRef, Config co
}
}

private void ensureDigest(LayoutRef ref, Path path) {
if (ref.getTag() == null) {
throw new OrasException("Missing ref");
}
if (!SupportedAlgorithm.isSupported(ref.getTag())) {
throw new OrasException("Unsupported digest: %s".formatted(ref.getTag()));
}
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getTag());
String pathDigest = algorithm.digest(path);
if (!ref.getTag().equals(pathDigest)) {
throw new OrasException("Digest mismatch: %s != %s".formatted(ref.getTag(), pathDigest));
}
}

/**
* Builder for the registry
*/
Expand Down
33 changes: 19 additions & 14 deletions src/main/java/land/oras/Registry.java
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ public Layer pushBlob(ContainerRef containerRef, Path blob, Map<String, String>
@Override
public Layer pushBlob(ContainerRef containerRef, byte[] data) {
String digest = containerRef.getAlgorithm().digest(data);
if (containerRef.getDigest() != null) {
ensureDigest(containerRef, data);
}
if (hasBlob(containerRef.withDigest(digest))) {
LOG.info("Blob already exists: {}", digest);
return Layer.fromData(containerRef, data);
Expand Down Expand Up @@ -517,21 +520,11 @@ public boolean hasBlob(ContainerRef containerRef) {
*/
@Override
public byte[] getBlob(ContainerRef containerRef) {
if (!hasBlob(containerRef)) {
throw new OrasException(new OrasHttpClient.ResponseWrapper<>("", 404, Map.of()));
}
URI uri = URI.create("%s://%s".formatted(getScheme(), containerRef.getBlobsPath()));
OrasHttpClient.ResponseWrapper<String> response =
client.get(uri, Map.of(Const.ACCEPT_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE));
logResponse(response);

// Switch to bearer auth if needed and retry first request
if (switchTokenAuth(containerRef, response)) {
response = client.get(uri, Map.of(Const.ACCEPT_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE));
logResponse(response);
try (InputStream is = fetchBlob(containerRef)) {
return ensureDigest(containerRef, is.readAllBytes());
} catch (IOException e) {
throw new OrasException("Failed to get blob", e);
}
handleError(response);
return response.response().getBytes();
}

@Override
Expand Down Expand Up @@ -620,6 +613,18 @@ private OrasHttpClient.ResponseWrapper<String> getManifestResponse(ContainerRef
return client.get(uri, Map.of("Accept", Const.MANIFEST_ACCEPT_TYPE));
}

private byte[] ensureDigest(ContainerRef ref, byte[] data) {
if (ref.getDigest() == null) {
throw new OrasException("Missing digest");
}
SupportedAlgorithm algorithm = SupportedAlgorithm.fromDigest(ref.getDigest());
String dataDigest = algorithm.digest(data);
if (!ref.getDigest().equals(dataDigest)) {
throw new OrasException("Digest mismatch: %s != %s".formatted(ref.getTag(), dataDigest));
}
return data;
}

/**
* Switch the current authentication to token auth
* @param response The response
Expand Down
44 changes: 31 additions & 13 deletions src/test/java/land/oras/OCILayoutTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ void shouldPushArtifact() throws IOException {

Path path = layoutPath.resolve("shouldPushArtifact");

LayoutRef layoutRef =
LayoutRef.parse("%s@sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4"
.formatted(path.toString()));
byte[] content = "hi".getBytes(StandardCharsets.UTF_8);
String digest = SupportedAlgorithm.SHA256.digest(content);

LayoutRef layoutRef = LayoutRef.parse("%s@%s".formatted(path.toString(), digest));
OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build();

// Not implemented
Expand All @@ -136,14 +137,14 @@ void shouldPushArtifact() throws IOException {
ociLayout.pushBlob(layoutRef, "hi".getBytes(StandardCharsets.UTF_8));

// Assert file exists
assertBlobExists(path, "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4");
assertBlobContent(path, "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4", "hi");
assertBlobExists(path, digest);
assertBlobContent(path, digest, "hi");

// Push again
ociLayout.pushBlob(layoutRef, "hi".getBytes(StandardCharsets.UTF_8));

assertBlobExists(path, "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4");
assertBlobContent(path, "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4", "hi");
assertBlobExists(path, digest);
assertBlobContent(path, digest, "hi");
}

@Test
Expand All @@ -165,6 +166,27 @@ void cannotPushBlobWithoutTagOrDigest() throws IOException {
});
}

@Test
void cannotPushWithInvalidDigest() {
Path invalidBlobPushDir = layoutPath.resolve("cannotPushWithInvalidDigest");

LayoutRef wrongDigest1 = LayoutRef.parse("%s@sha234:1234".formatted(invalidBlobPushDir.toString()));
OCILayout ociLayout =
OCILayout.Builder.builder().defaults(invalidBlobPushDir).build();

// Push more blobs
assertThrows(OrasException.class, () -> {
ociLayout.pushBlob(wrongDigest1, "hi".getBytes(StandardCharsets.UTF_8));
});

LayoutRef wrongDigest2 = LayoutRef.parse("%s@sha256:1234".formatted(invalidBlobPushDir.toString()));

// Push more blobs
assertThrows(OrasException.class, () -> {
ociLayout.pushBlob(wrongDigest2, "hi".getBytes(StandardCharsets.UTF_8));
});
}

@Test
void testShouldCopyArtifactFromRegistryIntoOciLayout() throws IOException {

Expand Down Expand Up @@ -273,10 +295,7 @@ void testShouldCopyImageIntoOciLayoutWithoutIndex() {

Manifest emptyManifest = Manifest.empty()
.withLayers(List.of(Layer.fromDigest(layer1.getDigest(), 2), Layer.fromDigest(layer2.getDigest(), 6)));
String manifestDigest =
SupportedAlgorithm.SHA256.digest(emptyManifest.toJson().getBytes(StandardCharsets.UTF_8));
String configDigest =
SupportedAlgorithm.SHA256.digest(Config.empty().toJson().getBytes(StandardCharsets.UTF_8));
String configDigest = Config.empty().getDigest();

// Push config and manifest
registry.pushConfig(containerRef.withDigest(configDigest), Config.empty());
Expand Down Expand Up @@ -344,8 +363,7 @@ void testShouldCopyImageIntoOciLayoutWithIndex() throws IOException {
.withLayers(List.of(Layer.fromDigest(layer1.getDigest(), 2), Layer.fromDigest(layer2.getDigest(), 6)));
String manifestDigest =
SupportedAlgorithm.SHA256.digest(emptyManifest.toJson().getBytes(StandardCharsets.UTF_8));
String configDigest =
SupportedAlgorithm.SHA256.digest(Config.empty().toJson().getBytes(StandardCharsets.UTF_8));
String configDigest = Config.empty().getDigest();

// Push config and manifest
registry.pushConfig(containerRef.withDigest(configDigest), Config.empty());
Expand Down
18 changes: 16 additions & 2 deletions src/test/java/land/oras/RegistryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ void before() {
registry.withFollowOutput();
}

@Test
void shouldFailToPushBlobForInvalidDigest() {
Registry registry = Registry.Builder.builder()
.defaults("myuser", "mypass")
.withInsecure(true)
.build();
ContainerRef containerRef1 = ContainerRef.parse(
"%s/library/artifact-text@sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
.formatted(this.registry.getRegistry()));
// Ensure the blob is deleted
assertThrows(OrasException.class, () -> {
registry.pushBlob(containerRef1, "invalid".getBytes());
});
}

@Test
void shouldPushAndGetBlobThenDeleteWithSha256() {
Registry registry = Registry.Builder.builder()
Expand Down Expand Up @@ -515,8 +530,7 @@ void testNotFailToPullArtifactFromImage() {
Manifest emptyManifest = Manifest.empty().withLayers(List.of(Layer.fromDigest(emptyLayer.getDigest(), 2)));
String manifestDigest =
SupportedAlgorithm.SHA256.digest(emptyManifest.toJson().getBytes(StandardCharsets.UTF_8));
String configDigest =
SupportedAlgorithm.SHA256.digest(Config.empty().toJson().getBytes(StandardCharsets.UTF_8));
String configDigest = Config.empty().getDigest();

// Push config and manifest
registry.pushConfig(containerRef.withDigest(configDigest), Config.empty());
Expand Down
25 changes: 16 additions & 9 deletions src/test/java/land/oras/RegistryWireMockTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import land.oras.exception.OrasException;
import land.oras.utils.Const;
import land.oras.utils.JsonUtils;
import land.oras.utils.SupportedAlgorithm;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
Expand All @@ -64,14 +65,16 @@ public class RegistryWireMockTest {
@Test
void shouldRedirectWhenDownloadingBlob(WireMockRuntimeInfo wmRuntimeInfo) {

String digest = SupportedAlgorithm.SHA256.digest("blob-data".getBytes());

// Return data from wiremock
WireMock wireMock = wmRuntimeInfo.getWireMock();
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/artifact-text/blobs/sha256:one"))
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/artifact-text/blobs/%s".formatted(digest)))
.willReturn(WireMock.temporaryRedirect("http://localhost:%d/v2/library/artifact-text/blobs/sha256:other"
.formatted(wmRuntimeInfo.getHttpPort()))));

// Return blob on new location
wireMock.register(WireMock.head(WireMock.urlEqualTo("/v2/library/artifact-text/blobs/sha256:other"))
wireMock.register(WireMock.head(WireMock.urlEqualTo("/v2/library/artifact-text/blobs/%s".formatted(digest)))
.willReturn(WireMock.ok()));
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/artifact-text/blobs/sha256:other"))
.willReturn(WireMock.ok().withBody("blob-data")));
Expand All @@ -84,7 +87,7 @@ void shouldRedirectWhenDownloadingBlob(WireMockRuntimeInfo wmRuntimeInfo) {

ContainerRef containerRef =
ContainerRef.parse("localhost:%d/library/artifact-text".formatted(wmRuntimeInfo.getHttpPort()));
byte[] blob = registry.getBlob(containerRef.withDigest("sha256:one"));
byte[] blob = registry.getBlob(containerRef.withDigest(digest));
assertEquals("blob-data", new String(blob));
}

Expand Down Expand Up @@ -270,9 +273,11 @@ void shouldRetryBlobUpload(WireMockRuntimeInfo wmRuntimeInfo) throws IOException
@Test
void shouldGetToken(WireMockRuntimeInfo wmRuntimeInfo) {

String digest = SupportedAlgorithm.SHA256.digest("blob-data".getBytes());

// Return data from wiremock
WireMock wireMock = wmRuntimeInfo.getWireMock();
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/get-token/blobs/sha256:one"))
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/get-token/blobs/%s".formatted(digest)))
.inScenario("get token")
.willReturn(WireMock.unauthorized()
.withHeader(
Expand All @@ -289,7 +294,7 @@ void shouldGetToken(WireMockRuntimeInfo wmRuntimeInfo) {
"fake-token", "access-token", 300, ZonedDateTime.now())))));

// On the second call we return ok
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/get-token/blobs/sha256:one"))
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/get-token/blobs/%s".formatted(digest)))
.inScenario("get token")
.whenScenarioStateIs("get")
.willReturn(WireMock.ok().withBody("blob-data")));
Expand All @@ -302,16 +307,18 @@ void shouldGetToken(WireMockRuntimeInfo wmRuntimeInfo) {

ContainerRef containerRef =
ContainerRef.parse("localhost:%d/library/get-token".formatted(wmRuntimeInfo.getHttpPort()));
byte[] blob = registry.getBlob(containerRef.withDigest("sha256:one"));
byte[] blob = registry.getBlob(containerRef.withDigest(digest));
assertEquals("blob-data", new String(blob));
}

@Test
void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {

String digest = SupportedAlgorithm.SHA256.digest("blob-data".getBytes());

// Return data from wiremock
WireMock wireMock = wmRuntimeInfo.getWireMock();
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/refresh-token/blobs/sha256:one"))
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/refresh-token/blobs/%s".formatted(digest)))
.inScenario("get token")
.willReturn(WireMock.forbidden()
.withHeader(
Expand All @@ -328,7 +335,7 @@ void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {
"fake-token", "access-token", 300, ZonedDateTime.now())))));

// On the second call we return ok
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/refresh-token/blobs/sha256:one"))
wireMock.register(WireMock.any(WireMock.urlEqualTo("/v2/library/refresh-token/blobs/%s".formatted(digest)))
.inScenario("get token")
.whenScenarioStateIs("get")
.willReturn(WireMock.ok().withBody("blob-data")));
Expand All @@ -341,7 +348,7 @@ void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {

ContainerRef containerRef =
ContainerRef.parse("localhost:%d/library/refresh-token".formatted(wmRuntimeInfo.getHttpPort()));
byte[] blob = registry.getBlob(containerRef.withDigest("sha256:one"));
byte[] blob = registry.getBlob(containerRef.withDigest(digest));
assertEquals("blob-data", new String(blob));
}
}