diff --git a/src/main/java/land/oras/ContainerRef.java b/src/main/java/land/oras/ContainerRef.java index 46299437..7867e03b 100644 --- a/src/main/java/land/oras/ContainerRef.java +++ b/src/main/java/land/oras/ContainerRef.java @@ -484,7 +484,7 @@ public String getEffectiveRegistry(Registry target) { ? target.getRegistry() : determineFirstUnqualifiedSearchRegistry(target); } - // The effective registry can we rewrotten by the registry configuration. + // The effective registry can be rewritten by the registry configuration. // Ensure to return it ContainerRef rewrite = target.getRegistriesConf().rewrite(this); return rewrite.getRegistry(); @@ -506,14 +506,15 @@ public ContainerRef forRegistry(String registry) { public boolean isInsecure(Registry registry) { String effectiveRegistry = getEffectiveRegistry(registry); ContainerRef effectiveRef = forRegistry(effectiveRegistry); - if (registry.getRegistriesConf().isInsecure(effectiveRef)) { + // Configuration is authoritative over the current registry + if (registry.getRegistriesConf().isInsecure(registry, effectiveRef)) { LOG.debug( "Access to container reference {} is insecure by location configuration for registry {}", this, effectiveRegistry); return true; } - return registry.isInsecure(); + return false; } /** diff --git a/src/main/java/land/oras/Registry.java b/src/main/java/land/oras/Registry.java index bd4c93de..17371c5f 100644 --- a/src/main/java/land/oras/Registry.java +++ b/src/main/java/land/oras/Registry.java @@ -39,6 +39,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.BiFunction; import java.util.function.Supplier; import land.oras.auth.AuthProvider; import land.oras.auth.AuthStoreAuthenticationProvider; @@ -168,6 +169,9 @@ public boolean mountBlob(ContainerRef sourceRef, ContainerRef targetRef) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().mountBlob(sourceRef, targetRef); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().mountBlob(sourceRef, targetRef); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsMountPath(this, sourceRef))); HttpClient.ResponseWrapper response = client.post( uri, @@ -341,9 +345,23 @@ public Registry withAuthToken(String authToken) { * @return The new registry */ public Registry asInsecure() { + LOG.debug("Creating a new registry as insecure (HTTP)"); return new Builder().from(this).withInsecure(true).build(); } + /** + * Return a new registry as secure (HTTPS, TLS verified) but with same settings + * @return The new registry + */ + public Registry asSecure() { + LOG.debug("Creating a new registry as secure (HTTPS, TLS verified)"); + return new Builder() + .from(this) + .withInsecure(false) + .withSkipTlsVerify(false) + .build(); + } + /** * Get the registry URL * @return The registry URL @@ -363,6 +381,9 @@ public Tags getTags(ContainerRef containerRef) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().getTags(containerRef); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().getTags(containerRef); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getTagsPath(this))); HttpClient.ResponseWrapper response = client.get( uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE), Scopes.of(ref), authProvider); @@ -378,6 +399,9 @@ public Tags getTags(ContainerRef containerRef, int n, @Nullable String last) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().getTags(containerRef); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().getTags(containerRef); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getTagsPath(this, n, last))); HttpClient.ResponseWrapper response = client.get( uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE), Scopes.of(ref), authProvider); @@ -390,10 +414,17 @@ public Tags getTags(ContainerRef containerRef, int n, @Nullable String last) { @Override public Repositories getRepositories() { if (registry != null - && getRegistriesConf().isInsecure(ContainerRef.parse(registry).forRegistry(registry)) + && getRegistriesConf() + .isInsecure(this, ContainerRef.parse(registry).forRegistry(registry)) && !this.isInsecure()) { return asInsecure().getRepositories(); } + if (registry != null + && !getRegistriesConf() + .isInsecure(this, ContainerRef.parse(registry).forRegistry(registry)) + && this.isInsecure()) { + return asSecure().getRepositories(); + } ContainerRef ref = ContainerRef.parse("default").forRegistry(this); URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getRepositoriesPath(this))); HttpClient.ResponseWrapper response = client.get( @@ -412,6 +443,9 @@ public Referrers getReferrers(ContainerRef containerRef, @Nullable ArtifactType if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().getReferrers(containerRef, artifactType); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().getReferrers(containerRef, artifactType); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getReferrersPath(this, artifactType))); HttpClient.ResponseWrapper response = client.get( uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_INDEX_MEDIA_TYPE), Scopes.of(ref), authProvider); @@ -430,6 +464,10 @@ public void deleteManifest(ContainerRef containerRef) { asInsecure().deleteManifest(containerRef); return; } + if (!ref.isInsecure(this) && this.isInsecure()) { + asSecure().deleteManifest(containerRef); + return; + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this))); HttpClient.ResponseWrapper response = client.delete(uri, Map.of(), Scopes.of(ref), authProvider); logResponse(response); @@ -453,6 +491,9 @@ public Manifest pushManifest(ContainerRef containerRef, Manifest manifest) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushManifest(ref, manifest); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushManifest(ref, manifest); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this))); byte[] manifestData = manifest.getJson() != null ? manifest.getJson().getBytes() @@ -494,6 +535,9 @@ public Index pushIndex(ContainerRef containerRef, Index index) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushIndex(containerRef, index); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushIndex(containerRef, index); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this))); byte[] indexData = JsonUtils.toJson(index).getBytes(); LOG.debug("Index data to push: {}", new String(indexData, StandardCharsets.UTF_8)); @@ -518,6 +562,10 @@ public void deleteBlob(ContainerRef containerRef) { asInsecure().deleteBlob(containerRef); return; } + if (!ref.isInsecure(this) && this.isInsecure()) { + asSecure().deleteBlob(containerRef); + return; + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this))); HttpClient.ResponseWrapper response = client.delete(uri, Map.of(), Scopes.of(ref), authProvider); logResponse(response); @@ -526,9 +574,20 @@ public void deleteBlob(ContainerRef containerRef) { @Override public void pullArtifact(ContainerRef containerRef, Path path, boolean overwrite) { + withMirrorFallback(containerRef, (reg, ref) -> { + reg.pullArtifactDirect(ref, path, overwrite); + return null; + }); + } + + private void pullArtifactDirect(ContainerRef containerRef, Path path, boolean overwrite) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - asInsecure().pullArtifact(containerRef, path, overwrite); + asInsecure().pullArtifactDirect(containerRef, path, overwrite); + return; + } + if (!ref.isInsecure(this) && this.isInsecure()) { + asSecure().pullArtifactDirect(containerRef, path, overwrite); return; } // Only collect layer that are files @@ -600,6 +659,9 @@ public Layer pushBlob(ContainerRef containerRef, Path blob, Map if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushBlob(ref, blob, annotations); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushBlob(ref, blob, annotations); + } // This might not works with registries performing HEAD request if (hasBlob(ref.withDigest(digest))) { LOG.info("Blob already exists: {}", digest); @@ -661,6 +723,9 @@ public Layer pushBlob(ContainerRef ref, long size, Supplier stream, if (containerRef.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushBlob(ref, size, stream, annotations); } + if (!containerRef.isInsecure(this) && this.isInsecure()) { + return asSecure().pushBlob(ref, size, stream, annotations); + } if (hasBlob(containerRef)) { LOG.info("Blob already exists: {}", digest); return Layer.fromDigest(digest, size).withAnnotations(annotations); @@ -710,6 +775,9 @@ public Layer pushBlob(ContainerRef containerRef, byte[] data) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushBlob(containerRef, data); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushBlob(containerRef, data); + } if (ref.getDigest() != null) { ensureDigest(ref, data); } @@ -779,6 +847,9 @@ public Layer pushBlobChunked(ContainerRef containerRef, Path blob, long chunkSiz if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushBlobChunked(containerRef, blob, chunkSize); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushBlobChunked(containerRef, blob, chunkSize); + } if (hasBlob(ref.withDigest(digest))) { LOG.info("Blob already exists: {}", digest); return Layer.fromFile(blob, ref.getAlgorithm()); @@ -816,6 +887,9 @@ public Layer pushBlobChunked(ContainerRef containerRef, InputStream stream, long if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().pushBlobChunked(containerRef, stream, totalSize, chunkSize); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().pushBlobChunked(containerRef, stream, totalSize, chunkSize); + } if (hasBlob(ref)) { LOG.info("Blob already exists: {}", digest); return Layer.fromDigest(digest, totalSize); @@ -928,6 +1002,9 @@ private HttpClient.ResponseWrapper headBlob(ContainerRef containerRef) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().headBlob(containerRef); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().headBlob(containerRef); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this))); HttpClient.ResponseWrapper response = client.head( uri, @@ -945,9 +1022,16 @@ private HttpClient.ResponseWrapper headBlob(ContainerRef containerRef) { */ @Override public byte[] getBlob(ContainerRef containerRef) { + return withMirrorFallback(containerRef, (reg, ref) -> reg.getBlobDirect(ref)); + } + + private byte[] getBlobDirect(ContainerRef containerRef) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - return asInsecure().getBlob(containerRef); + return asInsecure().getBlobDirect(containerRef); + } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().getBlobDirect(containerRef); } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this))); HttpClient.ResponseWrapper response = client.get( @@ -964,9 +1048,20 @@ public byte[] getBlob(ContainerRef containerRef) { @Override public void fetchBlob(ContainerRef containerRef, Path path) { + withMirrorFallback(containerRef, (reg, ref) -> { + reg.fetchBlobDirect(ref, path); + return null; + }); + } + + private void fetchBlobDirect(ContainerRef containerRef, Path path) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - asInsecure().fetchBlob(containerRef, path); + asInsecure().fetchBlobDirect(containerRef, path); + return; + } + if (!ref.isInsecure(this) && this.isInsecure()) { + asSecure().fetchBlobDirect(containerRef, path); return; } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this))); @@ -983,9 +1078,16 @@ public void fetchBlob(ContainerRef containerRef, Path path) { @Override public InputStream fetchBlob(ContainerRef containerRef) { + return withMirrorFallback(containerRef, (reg, ref) -> reg.fetchBlobDirect(ref)); + } + + private InputStream fetchBlobDirect(ContainerRef containerRef) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - return asInsecure().fetchBlob(containerRef); + return asInsecure().fetchBlobDirect(containerRef); + } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().fetchBlobDirect(containerRef); } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getBlobsPath(this))); HttpClient.ResponseWrapper response = client.download( @@ -1098,6 +1200,9 @@ boolean exists(ContainerRef containerRef) { if (ref.isInsecure(this) && !this.isInsecure()) { return asInsecure().exists(containerRef); } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().exists(containerRef); + } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this))); HttpClient.ResponseWrapper response = client.head(uri, Map.of(Const.ACCEPT_HEADER, Const.MANIFEST_ACCEPT_TYPE), Scopes.of(ref), authProvider); @@ -1106,14 +1211,58 @@ boolean exists(ContainerRef containerRef) { } /** - * Get a manifest response + * Execute an operation with mirror fallback. Mirrors are tried in order; if all fail the + * operation is retried against the original registry. + * @param containerRef The original container reference used to look up mirrors + * @param operation A function (registry, ref) → result; called for each candidate + * @return The result from the first successful invocation + */ + private T withMirrorFallback(ContainerRef containerRef, BiFunction operation) { + List mirrors = registriesConf.getMirrors(containerRef); + for (RegistriesConf.MirrorConfig mirror : mirrors) { + String mirrorLocation = mirror.location(); + if (mirrorLocation == null || mirrorLocation.isBlank()) continue; + ContainerRef mirrorRef = registriesConf.rewriteForMirror(containerRef, mirror); + // Use only the host[:port] for copy() — the path prefix is already baked into mirrorRef + // by rewriteForMirror, so including it here would double-apply the path. + // Strip any scheme (e.g., "https://") before extracting the host. + String locationNoScheme = mirrorLocation.contains("://") + ? mirrorLocation.substring(mirrorLocation.indexOf("://") + 3) + : mirrorLocation; + String mirrorHost = locationNoScheme.contains("/") + ? locationNoScheme.substring(0, locationNoScheme.indexOf('/')) + : locationNoScheme; + // Transport settings come from the mirror config, not from the parent registry. + // Use asInsecure/asSecure unconditionally so an insecure parent never leaks HTTP + // onto a secure mirror, and a secure parent never blocks an insecure mirror. + Registry mirrorBase = copy(mirrorHost); + Registry mirrorRegistry = mirror.isInsecure() ? mirrorBase.asInsecure() : mirrorBase.asSecure(); + try { + LOG.debug("Trying mirror {} for {}", mirrorLocation, containerRef); + return operation.apply(mirrorRegistry, mirrorRef); + } catch (OrasException e) { + LOG.warn("Mirror {} failed for {}: {}", mirrorLocation, containerRef, e.getMessage()); + } + } + return operation.apply(this, containerRef); + } + + /** + * Get a manifest response, trying configured mirrors before the original registry. * @param containerRef The container * @return The response */ private HttpClient.ResponseWrapper getManifestResponse(ContainerRef containerRef) { + return withMirrorFallback(containerRef, (reg, ref) -> reg.getManifestResponseDirect(ref)); + } + + private HttpClient.ResponseWrapper getManifestResponseDirect(ContainerRef containerRef) { ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this); if (ref.isInsecure(this) && !this.isInsecure()) { - return asInsecure().getManifestResponse(containerRef); + return asInsecure().getManifestResponseDirect(containerRef); + } + if (!ref.isInsecure(this) && this.isInsecure()) { + return asSecure().getManifestResponseDirect(containerRef); } URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getManifestsPath(this))); HttpClient.ResponseWrapper response = @@ -1312,6 +1461,9 @@ ResolvedRegistry getResolvedHeaders(ContainerRef containerRef) { if (containerRef.isInsecure(this) && !this.isInsecure()) { return asInsecure().getResolvedHeaders(containerRef); } + if (!containerRef.isInsecure(this) && this.isInsecure()) { + return asSecure().getResolvedHeaders(containerRef); + } ContainerRef ref = containerRef.forRegistry(this); URI uri = URI.create( "%s://%s".formatted(getScheme(), ref.forRegistry(this).getManifestsPath(this))); @@ -1405,6 +1557,12 @@ public Builder from(Registry registry) { if (registry.meterRegistry != null) { this.registry.setMeterRegistry(registry.meterRegistry); } + if (registry.caFilePath != null) { + this.registry.setCaFilePath(registry.caFilePath); + } + if (registry.caContent != null) { + this.registry.setCaContent(registry.caContent); + } return this; } diff --git a/src/main/java/land/oras/auth/RegistriesConf.java b/src/main/java/land/oras/auth/RegistriesConf.java index 90f85ba2..acbb4c33 100644 --- a/src/main/java/land/oras/auth/RegistriesConf.java +++ b/src/main/java/land/oras/auth/RegistriesConf.java @@ -35,6 +35,7 @@ import java.util.Optional; import land.oras.ContainerRef; import land.oras.OrasModel; +import land.oras.Registry; import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.TomlUtils; @@ -99,23 +100,50 @@ public static RegistriesConf newConf() { return newConf(paths); } + /** + * The model of a mirror entry within a [[registry]] table. + * @param location The mirror registry location (host[:port][/path]). + * @param insecure Whether the mirror is insecure. + */ + @OrasModel + public record MirrorConfig( + @Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("insecure") Boolean insecure) { + /** + * Return true if this mirror should be accessed over plain HTTP or with unverified TLS. + * @return true if insecure + */ + public boolean isInsecure() { + return insecure != null && insecure; + } + } + /** * The model of the registry configuration * @param prefix The prefix to match against container references. * @param location The registry location * @param blocked Whether the registry is blocked. If true, the registry is blocked and cannot be used for pulling or pushing images. * @param insecure Whether the registry is insecure. If true, the registry is considered insecure and may allow connections over HTTP or with invalid TLS certificates. + * @param mirrors Ordered list of mirror entries to try before the registry location. */ @OrasModel record RegistryConfig( @Nullable @JsonProperty("prefix") String prefix, @Nullable @JsonProperty("location") String location, @Nullable @JsonProperty("blocked") Boolean blocked, - @Nullable @JsonProperty("insecure") Boolean insecure) { + @Nullable @JsonProperty("insecure") Boolean insecure, + @Nullable @JsonProperty("mirror") List mirrors) { + /** + * Return true if this registry is blocked and cannot be used for pulling or pushing images. + * @return true if blocked + */ public boolean isBlocked() { return blocked != null && blocked; } + /** + * Return true if this registry should be accessed over plain HTTP or with unverified TLS. + * @return true if insecure + */ public boolean isInsecure() { return insecure != null && insecure; } @@ -254,11 +282,14 @@ public boolean isBlocked(ContainerRef location) { /** * Check if the given registry is marked as insecure in the configuration. + * If no entry found, we fall back to Registry configuration + * If the entry is found we use the insecure flag (or default true) + * @param registry The registry object * @param location the registry location to check for insecurity. * @return true if the registry is marked as insecure, false otherwise. */ - public boolean isInsecure(ContainerRef location) { - return selectMatchingTable(location).map(RegistryConfig::isInsecure).orElse(false); + public boolean isInsecure(Registry registry, ContainerRef location) { + return selectMatchingTable(location).map(RegistryConfig::isInsecure).orElse(registry.isInsecure()); } /** @@ -333,6 +364,58 @@ public ContainerRef rewrite(ContainerRef ref) { return ContainerRef.parse(rewrittenRefString); } + /** + * Return the ordered list of mirrors configured for the registry that matches the given reference. + * @param ref the container reference to look up mirrors for. + * @return an unmodifiable list of mirror configs (may be empty). + */ + public List getMirrors(ContainerRef ref) { + Optional matchingConfig = selectMatchingTable(ref); + if (matchingConfig.isEmpty() || matchingConfig.get().mirrors() == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(matchingConfig.get().mirrors()); + } + + /** + * Rewrite the given container reference to use the mirror's location, replacing the registry host. + * @param ref the original container reference. + * @param mirror the mirror configuration to apply. + * @return the rewritten reference pointing at the mirror. + */ + public ContainerRef rewriteForMirror(ContainerRef ref, MirrorConfig mirror) { + String mirrorLocation = mirror.location(); + if (mirrorLocation == null || mirrorLocation.isBlank()) { + return ref; + } + // Strip trailing slashes to prevent double-slash segments in the rewritten ref + mirrorLocation = mirrorLocation.replaceAll("/+$", ""); + if (mirrorLocation.isBlank()) { + return ref; + } + // Always build from components to correctly handle: + // - unqualified refs (toString() omits the registry) + // - digest-only refs (tag is null, toString() would produce ":null") + String namespace = ref.getNamespace(); + String repository = ref.getRepository(); + String tag = ref.getTag(); + String digest = ref.getDigest(); + StringBuilder sb = new StringBuilder(mirrorLocation); + if (namespace != null && !namespace.isEmpty()) { + sb.append("/").append(namespace); + } + sb.append("/").append(repository); + if (tag != null && !tag.isEmpty()) { + sb.append(":").append(tag); + } + if (digest != null && !digest.isEmpty()) { + sb.append("@").append(digest); + } + String rewrittenRefString = sb.toString(); + LOG.debug("Rewriting '{}' to mirror '{}'", ref, rewrittenRefString); + return ContainerRef.parse(rewrittenRefString); + } + /** * Select the matching registry configuration table for the container reference. * @param ref the container reference to find the matching registry configuration for. diff --git a/src/test/java/land/oras/ClassAnnotationsTest.java b/src/test/java/land/oras/ClassAnnotationsTest.java index 9be7273e..7d9d8dcd 100644 --- a/src/test/java/land/oras/ClassAnnotationsTest.java +++ b/src/test/java/land/oras/ClassAnnotationsTest.java @@ -49,7 +49,7 @@ void shouldHaveAnnotationOnModel() { .loadClasses()); // Check number of classes - assertEquals(26, modelClasses.size()); + assertEquals(27, modelClasses.size()); // Check classes assertTrue(modelClasses.contains(Annotations.class)); @@ -83,7 +83,7 @@ void shouldHaveAnnotationOnAuthPackage() { .loadClasses()); // Check number of classes - assertEquals(8, modelClasses.size()); + assertEquals(9, modelClasses.size()); } } } diff --git a/src/test/java/land/oras/RegistryMirrorTest.java b/src/test/java/land/oras/RegistryMirrorTest.java new file mode 100644 index 00000000..4fcf75c1 --- /dev/null +++ b/src/test/java/land/oras/RegistryMirrorTest.java @@ -0,0 +1,271 @@ +/*- + * =LICENSE= + * ORAS Java SDK + * === + * Copyright (C) 2024 - 2026 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import land.oras.utils.ZotUnsecureContainer; +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; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for [[registry.mirror]] support in registries.conf. + * Uses one real mirror container and one unreachable mirror address to verify + * that the SDK tries mirrors in order and falls back gracefully. + */ +@Testcontainers(disabledWithoutDocker = true) +@Execution(ExecutionMode.SAME_THREAD) +class RegistryMirrorTest { + + /** Working mirror — contains the test artifact. */ + @Container + private final ZotUnsecureContainer mirrorUp = new ZotUnsecureContainer().withStartupAttempts(3); + + @TempDir + private Path homeDir; + + @Test + void shouldFetchManifestViaMirrorWhenOriginalIsDown(@TempDir Path blobDir) throws Exception { + + // Push a test artifact to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "mirror-test.txt", "mirror content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/mirror-artifact:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // Build registries.conf: "original" registry (down) with 2 mirrors — + // mirror 1: localhost:59999 (down, connection refused) + // mirror 2: the running mirrorUp container + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "localhost:59999" + insecure = true + + [[registry.mirror]] + location = "%s" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + ContainerRef ref = ContainerRef.parse("localhost:59998/test/mirror-artifact:v1"); + Manifest manifest = registry.getManifest(ref); + assertNotNull(manifest, "Manifest should be fetched via the working mirror"); + }); + } + + @Test + void shouldPullArtifactViaMirrorWhenOriginalIsDown(@TempDir Path blobDir, @TempDir Path pullDir) throws Exception { + + // Push a test artifact (with a file layer) to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "mirror-artifact.txt", "mirror artifact content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/test/mirror-pull:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "localhost:59999" + insecure = true + + [[registry.mirror]] + location = "%s" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + ContainerRef ref = ContainerRef.parse("localhost:59998/test/mirror-pull:v1"); + registry.pullArtifact(ref, pullDir, true); + assertTrue( + Files.exists(pullDir.resolve("mirror-artifact.txt")), + "Artifact file should be pulled via the working mirror"); + }); + } + + @Test + void shouldFetchManifestViaInsecurePathPrefixedMirror(@TempDir Path blobDir) throws Exception { + + // Push the artifact under a path-prefixed location in the mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "mirror-prefix.txt", "path-prefix content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/prefix/test/mirror-prefix:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // Mirror location includes a path prefix → covers mirrorLocation.contains("/") branch. + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "%s/prefix" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + // Build WITHOUT insecure() so this.isInsecure() = false. + // mirror.isInsecure()=true && !isInsecure()=true → covers mirrorRegistry.asInsecure() branch. + Registry registry = Registry.builder().defaults().build(); + ContainerRef ref = ContainerRef.parse("localhost:59998/test/mirror-prefix:v1"); + Manifest manifest = registry.getManifest(ref); + assertNotNull(manifest, "Manifest should be fetched via path-prefixed insecure mirror"); + }); + } + + @Test + void shouldFetchBlobViaMirrorWhenOriginalIsDown(@TempDir Path blobDir, @TempDir Path fetchDir) throws Exception { + + // Push a blob to the working mirror + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + byte[] blobContent = "blob via mirror".getBytes(StandardCharsets.UTF_8); + ContainerRef mirrorRef = ContainerRef.parse(mirrorRegistry + "/test/mirror-blob:v1"); + Layer layer = setupRegistry.pushBlob(mirrorRef, blobContent); + + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "localhost:59998" + location = "localhost:59998" + + [[registry.mirror]] + location = "localhost:59999" + insecure = true + + [[registry.mirror]] + location = "%s" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + ContainerRef originalRef = + ContainerRef.parse("localhost:59998/test/mirror-blob:v1").withDigest(layer.getDigest()); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().insecure().defaults().build(); + + // getBlob + byte[] fetched = registry.getBlob(originalRef); + assertArrayEquals(blobContent, fetched, "getBlob should return blob content via mirror"); + + // fetchBlob(path) + Path dest = fetchDir.resolve("blob.bin"); + registry.fetchBlob(originalRef, dest); + try { + assertArrayEquals( + blobContent, Files.readAllBytes(dest), "fetchBlob(path) should write blob via mirror"); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // fetchBlob() / getBlobStream + try (InputStream is = registry.fetchBlob(originalRef)) { + assertArrayEquals(blobContent, is.readAllBytes(), "fetchBlob() stream should return blob via mirror"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + void shouldFetchManifestViaUnqualifiedReference(@TempDir Path blobDir) throws Exception { + + // Push to the mirror under the "library" namespace so it matches an unqualified pull + String mirrorRegistry = mirrorUp.getRegistry(); + Registry setupRegistry = Registry.builder().insecure(mirrorRegistry).build(); + Path testFile = createTestFile(blobDir, "mirror-unqualified.txt", "unqualified mirror content"); + ContainerRef mirrorArtifact = ContainerRef.parse(mirrorRegistry + "/library/mirror-unqualified:v1"); + setupRegistry.pushArtifact(mirrorArtifact, LocalPath.of(testFile)); + + // Configure docker.io with a mirror — unqualified refs resolve to docker.io by default + // language=toml + String registriesConf = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "%s" + insecure = true + """ + .formatted(mirrorRegistry); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + Registry registry = Registry.builder().defaults().build(); + // "library/mirror-unqualified:v1" is unqualified — registry defaults to docker.io, + // so rewriteForMirror must use the component-based path (not toString() substring) + ContainerRef ref = ContainerRef.parse("library/mirror-unqualified:v1"); + assertTrue(ref.isUnqualified()); + Manifest manifest = registry.getManifest(ref); + assertNotNull(manifest, "Manifest should be fetched via mirror for unqualified reference"); + }); + } + + private Path createTestFile(Path dir, String name, String content) throws IOException { + Path file = dir.resolve(name); + Files.writeString(file, content); + return file; + } +} diff --git a/src/test/java/land/oras/RegistryTlsTest.java b/src/test/java/land/oras/RegistryTlsTest.java index be0fdd56..42476445 100644 --- a/src/test/java/land/oras/RegistryTlsTest.java +++ b/src/test/java/land/oras/RegistryTlsTest.java @@ -22,14 +22,23 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyPair; import java.util.List; +import java.util.Map; import javax.net.ssl.SSLHandshakeException; import land.oras.exception.OrasException; +import land.oras.utils.SupportedAlgorithm; import land.oras.utils.TlsUtils; import land.oras.utils.ZotTlsContainer; import org.junit.jupiter.api.BeforeEach; 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; import org.testcontainers.junit.jupiter.Container; @@ -106,4 +115,174 @@ void shouldConnectWithSkipTlsVerify() { List repositories = registry.getRepositories().repositories(); assertNotNull(repositories); } + + @Test + void shouldBeSecureAfterAsSecure() { + // Start with a registry that skips TLS verification + Registry skipVerifyRegistry = Registry.builder() + .withRegistry(tlsRegistry.getRegistry()) + .withSkipTlsVerify(true) + .build(); + assertNotNull(skipVerifyRegistry.getRepositories().repositories()); + + // asSecure() resets both insecure and skipTlsVerify — now proper TLS is enforced + Registry secureRegistry = skipVerifyRegistry.asSecure(); + OrasException e = assertThrows(OrasException.class, secureRegistry::getRepositories); + assertInstanceOf(SSLHandshakeException.class, e.getCause()); + + // Verify the same host works when the CA cert is provided + Registry secureWithCa = Registry.builder() + .withRegistry(tlsRegistry.getRegistry()) + .withCaContent(tlsRegistry.getCaCertContent()) + .build(); + assertNotNull(secureWithCa.getRepositories().repositories()); + } + + @Test + @Execution(ExecutionMode.SAME_THREAD) + void shouldDowngradeToSecureWhenConfigExplicitlyNotInsecure(@TempDir Path homeDir) throws Exception { + // Build a registry as insecure (HTTP) but with the CA cert preserved so that + // after asSecure() the TLS handshake can succeed. + Registry insecureWithCa = Registry.builder() + .withRegistry(tlsRegistry.getRegistry()) + .withInsecure(true) + .withCaContent(tlsRegistry.getCaCertContent()) + .build(); + + // Config entry with insecure=false makes the config authoritative over the parent's + // insecure flag — ContainerRef.isInsecure() returns false, triggering the downgrade + // guard which calls asSecure() and routes the request over HTTPS. + // language=toml + String registriesConf = + """ + [[registry]] + location = "%s" + insecure = false + """ + .formatted(tlsRegistry.getRegistry()); + + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + TestUtils.withHome(homeDir, () -> { + // Rebuild inside withHome so RegistriesConf is loaded from the temp dir + Registry registry = Registry.builder() + .withRegistry(tlsRegistry.getRegistry()) + .withInsecure(true) + .withCaContent(tlsRegistry.getCaCertContent()) + .build(); + List repositories = registry.getRepositories().repositories(); + assertNotNull(repositories, "Should connect via HTTPS after downgrade from insecure registry"); + }); + } + + @Test + @Execution(ExecutionMode.SAME_THREAD) + void shouldUpgradeToSecureAllOperationsToSecureWhenConfigDefaultsToSecure( + @TempDir Path homeDir, @TempDir Path pullDir, @TempDir Path blobDir) throws Exception { + + // Registry is set to secure by default + // language=toml + String registriesConf = """ + [[registry]] + location = "%s" + """ + .formatted(tlsRegistry.getRegistry()); + TestUtils.createRegistriesConfFile(homeDir, registriesConf); + + Path testFile = blobDir.resolve("downgrade1.txt"); + Files.writeString(testFile, "tls-downgrade-content"); + + Path testFile2 = blobDir.resolve("downgrade2.txt"); + Files.writeString(testFile2, "other-content"); + + TestUtils.withHome(homeDir, () -> { + + // We create a registry with insecure flag (Set CA certifcates to ensure they are kept during copy) + Registry registry = Registry.builder() + .withRegistry(tlsRegistry.getRegistry()) + .withInsecure(true) + .withCaContent(tlsRegistry.getCaCertContent()) + .build(); + + ContainerRef ref = ContainerRef.parse(tlsRegistry.getRegistry() + "/test/upgrade:v1"); + assertFalse(ref.isInsecure(registry), "Resolved ref should be secure"); + + // Push artifact + Manifest manifest = registry.pushArtifact(ref, LocalPath.of(testFile)); + assertNotNull(manifest); + + // Push index + ContainerRef indexRef = ContainerRef.parse(tlsRegistry.getRegistry() + "/test/upgrade:v2"); + Index emptyIndex = Index.fromManifests(List.of()); + Index index = registry.pushIndex(indexRef, emptyIndex); + assertNotNull(index); + + // Push blob + byte[] streamBytes = "stream-blob".getBytes(StandardCharsets.UTF_8); + String streamDigest = SupportedAlgorithm.SHA256.digest(streamBytes); + ContainerRef streamRef = ref.withDigest(streamDigest); + registry.pushBlob(streamRef, streamBytes.length, () -> new ByteArrayInputStream(streamBytes), Map.of()); + + // Push chunked + registry.pushBlobChunked(ref, testFile, 8 * 1024); + + // Get repositories + assertFalse(registry.getRepositories().repositories().isEmpty()); + + // Get manifest + Manifest fetched = registry.getManifest(ref); + assertNotNull(fetched); + + // Probe descriptor + assertNotNull(registry.probeDescriptor(ref)); + + // Tags + Tags tags = registry.getTags(ref); + assertEquals(2, tags.tags().size(), "Only one tag is present"); + assertEquals("v1", tags.tags().get(0), "Tag should be v1"); + assertEquals("v2", tags.tags().get(1), "Tag should be v2"); + + // Exists + assertTrue(registry.exists(ref), "Ref must exists"); + + String layerDigest = manifest.getLayers().get(0).getDigest(); + assertNotNull(layerDigest, "Layer digest must not be null"); + ContainerRef blobRef = ref.withDigest(layerDigest); + + // Head blob + assertNotNull(registry.fetchBlobDescriptor(blobRef)); + + // Get blob + assertNotNull(registry.getBlob(blobRef)); + + // Fetch blob + Path dest = blobDir.resolve("fetched.bin"); + registry.fetchBlob(blobRef, dest); + assertTrue(Files.exists(dest)); + + // Fetch blob stream + try (InputStream is = registry.fetchBlob(blobRef)) { + assertTrue(is.readAllBytes().length > 0); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // Get referrers + assertNotNull(manifest.getDigest(), "Manifest index must not be null"); + Referrers referrers = registry.getReferrers(ref.withDigest(manifest.getDigest()), null); + assertNotNull(referrers); + + // Pull artifact + registry.pullArtifact(ref, pullDir, true); + + // Delete manifest + registry.deleteManifest(ref); + + // Delete blob + Layer layerToDelete = registry.pushBlob(ref.withTag("v3"), testFile2); + assertNotNull(layerToDelete); + assertNotNull(layerToDelete.getDigest(), "Layer digest must not be null"); + registry.deleteBlob(blobRef.withDigest(layerToDelete.getDigest())); + }); + } } diff --git a/src/test/java/land/oras/auth/RegistryConfTest.java b/src/test/java/land/oras/auth/RegistryConfTest.java index 7f091f6a..b509b8e9 100644 --- a/src/test/java/land/oras/auth/RegistryConfTest.java +++ b/src/test/java/land/oras/auth/RegistryConfTest.java @@ -30,6 +30,7 @@ import java.nio.file.Path; import java.util.List; import land.oras.ContainerRef; +import land.oras.Registry; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -51,102 +52,109 @@ static void init() throws Exception { @Test void shouldCheckRegistryStatusWithLocationOnly() { + Registry registry = Registry.builder().build(); + // With null - RegistriesConf.RegistryConfig registry = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, null); - assertEquals("localhost:5000", registry.location()); - assertNull(registry.blocked(), "Blocked should be null when not set"); - assertNull(registry.insecure(), "Insecure should be null when not set"); - assertFalse(registry.isBlocked(), "Registry should not be blocked when blocked is null"); - assertFalse(registry.isInsecure(), "Registry should not be insecure when insecure is null"); + RegistriesConf.RegistryConfig registryConfig = + new RegistriesConf.RegistryConfig(null, "localhost:5000", null, null, null); + assertEquals("localhost:5000", registryConfig.location()); + assertNull(registryConfig.blocked(), "Blocked should be null when not set"); + assertNull(registryConfig.insecure(), "Insecure should be null when not set"); + assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is null"); + assertFalse(registryConfig.isInsecure(), "Registry should not be insecure when insecure is null"); // Check some ref - RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); // With blocked true - registry = new RegistriesConf.RegistryConfig(null, "localhost:5000", true, null); - assertTrue(registry.isBlocked(), "Registry should be blocked when blocked is true"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", true, null, null); + assertTrue(registryConfig.isBlocked(), "Registry should be blocked when blocked is true"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); // With insecure true - registry = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, true); - assertTrue(registry.isInsecure(), "Registry should be insecure when insecure is true"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); - assertTrue(conf.isInsecure(ContainerRef.parse("localhost:5000/library/test:latest"))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5001/library/test:latest"))); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, true, null); + assertTrue(registryConfig.isInsecure(), "Registry should be insecure when insecure is true"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); + assertTrue(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); // With blocked false - registry = new RegistriesConf.RegistryConfig(null, "localhost:5000", false, null); - assertFalse(registry.isBlocked(), "Registry should not be blocked when blocked is false"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", false, null, null); + assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is false"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); // With insecure false - registry = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, false); - assertFalse(registry.isInsecure(), "Registry should be insecure when insecure is false"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5000/library/test:latest"))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5001/library/test:latest"))); + registryConfig = new RegistriesConf.RegistryConfig(null, "localhost:5000", null, false, null); + assertFalse(registryConfig.isInsecure(), "Registry should be insecure when insecure is false"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); } @Test void shouldCheckRegistryStatusWithPrefixExactMatch() { + Registry registry = Registry.builder().build(); + // With null - RegistriesConf.RegistryConfig registry = new RegistriesConf.RegistryConfig("localhost:5000", null, null, null); - assertEquals("localhost:5000", registry.prefix()); - assertNull(registry.blocked(), "Blocked should be null when not set"); - assertNull(registry.insecure(), "Insecure should be null when not set"); - assertFalse(registry.isBlocked(), "Registry should not be blocked when blocked is null"); - assertFalse(registry.isInsecure(), "Registry should not be insecure when insecure is null"); + RegistriesConf.RegistryConfig registryConfig = + new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null); + assertEquals("localhost:5000", registryConfig.prefix()); + assertNull(registryConfig.blocked(), "Blocked should be null when not set"); + assertNull(registryConfig.insecure(), "Insecure should be null when not set"); + assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is null"); + assertFalse(registryConfig.isInsecure(), "Registry should not be insecure when insecure is null"); // Check some ref - RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); assertDoesNotThrow(conf::enforceShortNameMode); // With blocked true - registry = new RegistriesConf.RegistryConfig("localhost:5000", null, true, null); - assertTrue(registry.isBlocked(), "Registry should be blocked when blocked is true"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, true, null, null); + assertTrue(registryConfig.isBlocked(), "Registry should be blocked when blocked is true"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertTrue(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); // With insecure true - registry = new RegistriesConf.RegistryConfig("localhost:5000", null, null, true); - assertTrue(registry.isInsecure(), "Registry should be insecure when insecure is true"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); - assertTrue(conf.isInsecure(ContainerRef.parse("localhost:5000/library/test:latest"))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5001/library/test:latest"))); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null); + assertTrue(registryConfig.isInsecure(), "Registry should be insecure when insecure is true"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); + assertTrue(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5001/library/test:latest"))); // With blocked false - registry = new RegistriesConf.RegistryConfig("localhost:5000", null, false, null); - assertFalse(registry.isBlocked(), "Registry should not be blocked when blocked is false"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, false, null, null); + assertFalse(registryConfig.isBlocked(), "Registry should not be blocked when blocked is false"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5000/library/test:latest"))); assertFalse(conf.isBlocked(ContainerRef.parse("localhost:5001/library/test:latest"))); // With insecure false - registry = new RegistriesConf.RegistryConfig("localhost:5000", null, null, false); - assertFalse(registry.isInsecure(), "Registry should be secure when insecure is false"); - conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5000/library/test:latest"))); - assertFalse(conf.isInsecure(ContainerRef.parse("localhost:5001/library/test:latest"))); + registryConfig = new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null); + assertFalse(registryConfig.isInsecure(), "Registry should be secure when insecure is false"); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(registryConfig))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5000/library/test:latest"))); + assertFalse(conf.isInsecure(registry, ContainerRef.parse("localhost:5001/library/test:latest"))); } @Test void checkWithHostNamePrefix() { - RegistriesConf.RegistryConfig registry = new RegistriesConf.RegistryConfig("*.example.com", null, true, null); + RegistriesConf.RegistryConfig registry = + new RegistriesConf.RegistryConfig("*.example.com", null, true, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); assertTrue(conf.isBlocked(ContainerRef.parse("registry.example.com/library/test:latest"))); assertTrue(conf.isBlocked(ContainerRef.parse("foobar.example.com/library/test:latest"))); @@ -155,14 +163,19 @@ void checkWithHostNamePrefix() { @Test void checkMultipleSettings() { + + Registry registry = Registry.builder().build(); + RegistriesConf.RegistryConfig registry1 = - new RegistriesConf.RegistryConfig("*.internal.local", null, false, true); + new RegistriesConf.RegistryConfig("*.internal.local", null, false, true, null); RegistriesConf.RegistryConfig registry2 = - new RegistriesConf.RegistryConfig("*.internal.local/public", null, true, null); + new RegistriesConf.RegistryConfig("*.internal.local/public", null, true, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry1, registry2))); - assertTrue(conf.isInsecure(ContainerRef.parse("registry.internal.local/library/test:latest"))); - assertFalse(conf.isInsecure(ContainerRef.parse( - "registry.internal.local/public/test:latest"))); // Match second rule because longest match wins + assertTrue(conf.isInsecure(registry, ContainerRef.parse("registry.internal.local/library/test:latest"))); + assertFalse(conf.isInsecure( + registry, + ContainerRef.parse( + "registry.internal.local/public/test:latest"))); // Match second rule because longest match wins assertFalse(conf.isBlocked(ContainerRef.parse("registry.internal.local/private/test:latest"))); assertTrue(conf.isBlocked(ContainerRef.parse("registry.internal.local/public/test:latest"))); } @@ -171,7 +184,7 @@ void checkMultipleSettings() { void shouldRewriteContainerRef() { // Just the domain RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("localhost:5000", "registry.example.com", null, null); + new RegistriesConf.RegistryConfig("localhost:5000", "registry.example.com", null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); ContainerRef originalRef = ContainerRef.parse("localhost:5000/library/test:latest"); ContainerRef rewrittenRef = conf.rewrite(originalRef); @@ -181,7 +194,7 @@ void shouldRewriteContainerRef() { // prefix = "example.com/foo" // location = "internal-registry-for-example.com/bar" registry = new RegistriesConf.RegistryConfig( - "example.com/foo", "internal-registry-for-example.com/bar", null, null); + "example.com/foo", "internal-registry-for-example.com/bar", null, null, null); conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); originalRef = ContainerRef.parse("example.com/foo/library/test:latest"); rewrittenRef = conf.rewrite(originalRef); @@ -192,7 +205,7 @@ void shouldRewriteContainerRef() { void shouldRewriteUnqualifiedContainerRef() { RegistriesConf.RegistryConfig registry = - new RegistriesConf.RegistryConfig("docker.io", "internal-registry-for-example.com", null, null); + new RegistriesConf.RegistryConfig("docker.io", "internal-registry-for-example.com", null, null, null); RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); // Without tag @@ -205,4 +218,156 @@ void shouldRewriteUnqualifiedContainerRef() { rewrittenRef = conf.rewrite(originalRef); assertEquals("internal-registry-for-example.com/library/alpine:1.0.0", rewrittenRef.toString()); } + + @Test + void shouldParseMirrorsFromToml() { + // language=toml + String toml = + """ + [[registry]] + prefix = "docker.io" + location = "docker.io" + + [[registry.mirror]] + location = "mirror1.example.com" + insecure = true + + [[registry.mirror]] + location = "mirror2.example.com" + insecure = false + """; + + RegistriesConf conf = RegistriesConf.newConf(List.of(writeTempToml(toml))); + + ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); + List mirrors = conf.getMirrors(ref); + + assertEquals(2, mirrors.size()); + assertEquals("mirror1.example.com", mirrors.get(0).location()); + assertTrue(mirrors.get(0).isInsecure()); + assertEquals("mirror2.example.com", mirrors.get(1).location()); + assertFalse(mirrors.get(1).isInsecure()); + } + + @Test + void shouldReturnEmptyMirrorsWhenNoneConfigured() { + RegistriesConf.RegistryConfig registry = + new RegistriesConf.RegistryConfig("docker.io", "docker.io", null, null, null); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + + ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); + assertTrue(conf.getMirrors(ref).isEmpty()); + } + + @Test + void shouldReturnEmptyMirrorsWhenNoMatchingRegistry() { + RegistriesConf.RegistryConfig registry = new RegistriesConf.RegistryConfig( + "other-registry.example.com", + "other-registry.example.com", + null, + null, + List.of(new RegistriesConf.MirrorConfig("mirror.example.com", false))); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(registry))); + + ContainerRef ref = ContainerRef.parse("docker.io/library/alpine:latest"); + assertTrue(conf.getMirrors(ref).isEmpty()); + } + + @Test + void shouldDefaultToSecureWhenInsecureNotSpecified() { + + Registry registry = Registry.builder().build(); + + ContainerRef ref = ContainerRef.parse("localhost:5000/library/test:latest"); + + // Secure by default + RegistriesConf.RegistryConfig noInsecureField = + new RegistriesConf.RegistryConfig("localhost:5000", null, null, null, null); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of(noInsecureField))); + assertFalse(conf.isInsecure(registry, ref), "No insecure field means secure by default"); + + // Secure + RegistriesConf.RegistryConfig explicitFalse = + new RegistriesConf.RegistryConfig("localhost:5000", null, null, false, null); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(explicitFalse))); + assertFalse(conf.isInsecure(registry, ref), "Registry must be secure by default"); + + // Insecure + RegistriesConf.RegistryConfig explicitTrue = + new RegistriesConf.RegistryConfig("localhost:5000", null, null, true, null); + conf = new RegistriesConf(new RegistriesConf.Config(List.of(explicitTrue))); + assertTrue(conf.isInsecure(registry, ref), "Registry is insecure"); + + // No matching entry → empty (no opinion, fall back to registry flag) + RegistriesConf empty = new RegistriesConf(new RegistriesConf.Config(List.of())); + assertFalse(empty.isInsecure(registry, ref), "Secure by default"); + } + + @Test + void shouldRewriteForMirror() { + RegistriesConf.MirrorConfig mirror = new RegistriesConf.MirrorConfig("mirror.example.com", false); + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of())); + + // Qualified ref + ContainerRef original = ContainerRef.parse("docker.io/library/alpine:latest"); + ContainerRef rewritten = conf.rewriteForMirror(original, mirror); + assertEquals("mirror.example.com/library/alpine:latest", rewritten.toString()); + + // Unqualified ref — toString() omits the registry, component-based rewrite must be used + ContainerRef unqualified = ContainerRef.parse("library/alpine:latest"); + assertTrue(unqualified.isUnqualified()); + ContainerRef rewrittenUnqualified = conf.rewriteForMirror(unqualified, mirror); + assertFalse(rewrittenUnqualified.isUnqualified()); + assertEquals("mirror.example.com/library/alpine:latest", rewrittenUnqualified.toString()); + + // Unqualified ref without explicit namespace + ContainerRef noNamespace = ContainerRef.parse("alpine:latest"); + assertTrue(noNamespace.isUnqualified()); + ContainerRef rewrittenNoNamespace = conf.rewriteForMirror(noNamespace, mirror); + assertFalse(rewrittenNoNamespace.isUnqualified()); + assertEquals("mirror.example.com/library/alpine:latest", rewrittenNoNamespace.toString()); + + // Digest-only ref (withDigest sets tag to null) — must not produce ":null" + ContainerRef digestOnly = ContainerRef.parse("docker.io/library/alpine:latest") + .withDigest("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"); + assertNull(digestOnly.getTag()); + ContainerRef rewrittenDigest = conf.rewriteForMirror(digestOnly, mirror); + assertFalse(rewrittenDigest.toString().contains(":null"), "Tag must not appear as ':null'"); + assertTrue(rewrittenDigest.toString().contains("sha256:"), "Digest must be preserved in rewritten ref"); + // ContainerRef.parse() defaults the tag to "latest" when omitted, so the rewritten ref + // carries both the digest and the default tag — the digest still drives resolution. + assertEquals( + "mirror.example.com/library/alpine:latest@sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + rewrittenDigest.toString()); + } + + @Test + void shouldRewriteForMirrorWithTrailingSlash() { + RegistriesConf conf = new RegistriesConf(new RegistriesConf.Config(List.of())); + ContainerRef original = ContainerRef.parse("docker.io/library/alpine:latest"); + + // Single trailing slash + RegistriesConf.MirrorConfig trailingSlash = new RegistriesConf.MirrorConfig("mirror.example.com/", false); + assertEquals( + "mirror.example.com/library/alpine:latest", + conf.rewriteForMirror(original, trailingSlash).toString()); + + // Multiple trailing slashes + RegistriesConf.MirrorConfig multiTrailingSlash = + new RegistriesConf.MirrorConfig("mirror.example.com/prefix//", false); + assertEquals( + "mirror.example.com/prefix/library/alpine:latest", + conf.rewriteForMirror(original, multiTrailingSlash).toString()); + } + + private Path writeTempToml(String content) { + try { + Path temp = Files.createTempFile("registries", ".conf"); + temp.toFile().deleteOnExit(); + Files.writeString(temp, content); + return temp; + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties index f3861380..e00d2807 100644 --- a/src/test/resources/junit-platform.properties +++ b/src/test/resources/junit-platform.properties @@ -1,4 +1,4 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.classes.default=concurrent junit.jupiter.execution.parallel.config.strategy=fixed -junit.jupiter.execution.parallel.config.fixed.parallelism=5 +junit.jupiter.execution.parallel.config.fixed.parallelism=2