From 3d8511bd720e1f9018170abffa4d9751c8aaa21d Mon Sep 17 00:00:00 2001 From: Pierre Delagrave Date: Mon, 6 Jul 2026 16:37:01 -0400 Subject: [PATCH 1/4] Fix `MavenSettings` equals and hashCode comparing no fields `@EqualsAndHashCode(onlyExplicitlyIncluded = true)` with no field included made any two `MavenSettings` instances equal, so the settings-equality guard in `MavenExecutionContextView#getMirrors(MavenSettings)` could never detect that supplied settings differ from those on the execution context. A mirror carried only in an LST's `MavenResolutionResult#getMavenSettings()` was silently dropped whenever the execution context also carried settings, sending resolution to public Maven Central instead of the mirror. Compare all nested settings types by value, excluding the lazily computed `mavenLocal` cache. --- .../org/openrewrite/maven/MavenSettings.java | 16 ++- .../maven/internal/RawRepositories.java | 3 + .../openrewrite/maven/MavenSettingsTest.java | 99 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java index 700fff54a73..e9f45adcf5c 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java @@ -47,26 +47,30 @@ @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @ToString(onlyExplicitlyIncluded = true) -@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Data @AllArgsConstructor @JacksonXmlRootElement(localName = "settings") public class MavenSettings { @Nullable + @ToString.Include String localRepository; @Nullable @NonFinal @JsonIgnore + @EqualsAndHashCode.Exclude MavenRepository mavenLocal; @Nullable + @ToString.Include Profiles profiles; @Nullable + @ToString.Include ActiveProfiles activeProfiles; @Nullable + @ToString.Include Mirrors mirrors; @Nullable @@ -203,7 +207,7 @@ private static boolean exists(Path path) { } public MavenSettings merge(@Nullable MavenSettings installSettings) { - return installSettings == null ? this : new MavenSettings( + return installSettings == null || equals(installSettings) ? this : new MavenSettings( localRepository == null ? installSettings.localRepository : localRepository, profiles == null ? installSettings.profiles : profiles.merge(installSettings.profiles), activeProfiles == null ? installSettings.activeProfiles : activeProfiles.merge(installSettings.activeProfiles), @@ -332,6 +336,8 @@ private Proxy interpolate(Proxy proxy) { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class Profiles { @@ -354,6 +360,8 @@ public Profiles merge(@Nullable Profiles profiles) { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class ActiveProfiles { @@ -401,6 +409,8 @@ public boolean isActive(String... activeProfiles) { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode + @ToString @AllArgsConstructor @NoArgsConstructor public static class Mirrors { @@ -442,6 +452,7 @@ public static class Mirror { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode @AllArgsConstructor @NoArgsConstructor public static class Servers { @@ -478,6 +489,7 @@ public static class Server { @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter + @EqualsAndHashCode @AllArgsConstructor @NoArgsConstructor public static class Proxies { diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java index 28dab9d9919..73992ccc67e 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/internal/RawRepositories.java @@ -28,6 +28,8 @@ @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter +@EqualsAndHashCode +@ToString public class RawRepositories { @JacksonXmlProperty(localName = "repository") @JacksonXmlElementWrapper(useWrapping = false) @@ -51,6 +53,7 @@ public static class Repository { @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @EqualsAndHashCode + @ToString @Getter public static class ArtifactPolicy { diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java index 6a930789891..2648c4b05e4 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java @@ -1048,4 +1048,103 @@ void parseProxies() { assertThat(proxy.getPassword()).isEqualTo("proxypass"); assertThat(proxy.getNonProxyHosts()).isEqualTo("localhost|*.example.com"); } + + /** + * {@link MavenExecutionContextView#getMirrors(MavenSettings)} decides whether supplied settings + * override those on the execution context by comparing them for equality, so {@link MavenSettings} + * must compare by value across all of its nested types. + */ + @Nested + class Equality { + //language=xml + private final String settingsXml = """ + + ~/.m2/repository + + repo + + + + repo + + false + 11 + + env + ci + + + + + internal + https://artifacts.example.com/maven2 + + false + + + + + + + + internal-mirror + https://artifacts.example.com/maven2 + central + + + + + internal-mirror + ci + secret + + 30000 + + + X-Custom + value + + + + + + + + my-proxy + proxy.example.com + + + anonymous-proxy.example.com + + + + """; + + private MavenSettings parse(String xml) { + MavenSettings settings = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), xml), ctx); + assertThat(settings).isNotNull(); + return settings; + } + + @Test + void equalWhenParsedFromSameDocument() { + assertThat(parse(settingsXml)) + .isEqualTo(parse(settingsXml)) + .hasSameHashCodeAs(parse(settingsXml)); + } + + @Test + void notEqualWhenMirrorsDiffer() { + MavenSettings withMirror = parse(settingsXml); + MavenSettings withoutMirror = parse(settingsXml.replaceAll("(?s).*", "")); + assertThat(withMirror).isNotEqualTo(withoutMirror); + } + + @Test + void lazyMavenLocalCacheDoesNotAffectEquality() { + MavenSettings primed = parse(settingsXml); + primed.getMavenLocal(); + assertThat(primed).isEqualTo(parse(settingsXml)); + } + } } From 30b842c4c21e104cda81865f74cece9fe1c0189c Mon Sep 17 00:00:00 2001 From: Pierre Delagrave Date: Mon, 6 Jul 2026 17:28:25 -0400 Subject: [PATCH 2/4] Preserve id-less entries when merging Maven settings Mirrors, profiles, and servers without an id collapsed onto a single null map key during `MavenSettings` merge, so all but one such entry were silently dropped. Merge all four entry types through one shared id-keyed helper that keeps id-less entries from both sides and cannot collide with real ids. --- .../org/openrewrite/maven/MavenSettings.java | 81 ++++---- .../openrewrite/maven/MavenSettingsTest.java | 184 ++++++++++++++++++ 2 files changed, 225 insertions(+), 40 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java index e9f45adcf5c..2d45152f835 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenSettings.java @@ -40,6 +40,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.function.Function; import java.util.function.UnaryOperator; import static java.util.Collections.emptyList; @@ -217,6 +218,41 @@ public MavenSettings merge(@Nullable MavenSettings installSettings) { ); } + /** + * Entries from {@code preferred} win over same-id entries from {@code other}, and duplicate ids + * within one side collapse to the last value at the first position, as Maven does. Entries without + * an id cannot be correlated, so both sides' are kept; merging two equal settings stays idempotent + * only through the equality short-circuit in {@link #merge(MavenSettings)}. + */ + private static List mergeById(List preferred, @Nullable List other, Function id) { + List merged = new ArrayList<>(preferred.size()); + Map indexOfId = new HashMap<>(); + for (T t : preferred) { + String tId = id.apply(t); + Integer existing = tId == null ? null : indexOfId.get(tId); + if (existing != null) { + merged.set(existing, t); + } else { + if (tId != null) { + indexOfId.put(tId, merged.size()); + } + merged.add(t); + } + } + if (other != null) { + for (T t : other) { + String tId = id.apply(t); + if (tId == null || !indexOfId.containsKey(tId)) { + if (tId != null) { + indexOfId.put(tId, merged.size()); + } + merged.add(t); + } + } + } + return merged; + } + public List getActiveRepositories(Iterable activeProfiles) { LinkedHashMap activeRepositories = new LinkedHashMap<>(); @@ -346,14 +382,8 @@ public static class Profiles { List profiles = emptyList(); public Profiles merge(@Nullable Profiles profiles) { - final Map merged = new LinkedHashMap<>(); - for (Profile profile : this.profiles) { - merged.put(profile.id, profile); - } - if (profiles != null) { - profiles.getProfiles().forEach(profile -> merged.putIfAbsent(profile.getId(), profile)); - } - return new Profiles(new ArrayList<>(merged.values())); + return new Profiles( + mergeById(this.profiles, profiles == null ? null : profiles.getProfiles(), Profile::getId)); } } @@ -419,14 +449,7 @@ public static class Mirrors { List mirrors = emptyList(); public Mirrors merge(@Nullable Mirrors mirrors) { - final Map merged = new LinkedHashMap<>(); - for (Mirror mirror : this.mirrors) { - merged.put(mirror.id, mirror); - } - if (mirrors != null) { - mirrors.getMirrors().forEach(mirror -> merged.putIfAbsent(mirror.getId(), mirror)); - } - return new Mirrors(new ArrayList<>(merged.values())); + return new Mirrors(mergeById(this.mirrors, mirrors == null ? null : mirrors.getMirrors(), Mirror::getId)); } } @@ -462,14 +485,7 @@ public static class Servers { List servers = emptyList(); public Servers merge(@Nullable Servers servers) { - final Map merged = new LinkedHashMap<>(); - for (Server server : this.servers) { - merged.put(server.id, server); - } - if (servers != null) { - servers.getServers().forEach(server -> merged.putIfAbsent(server.getId(), server)); - } - return new Servers(new ArrayList<>(merged.values())); + return new Servers(mergeById(this.servers, servers == null ? null : servers.getServers(), Server::getId)); } } @@ -498,22 +514,7 @@ public static class Proxies { List proxies = emptyList(); public Proxies merge(@Nullable Proxies proxies) { - final Map merged = new LinkedHashMap<>(); - int nullIndex = 0; - for (Proxy proxy : this.proxies) { - String key = proxy.id != null ? proxy.id : "__null_" + nullIndex++; - merged.put(key, proxy); - } - if (proxies != null) { - for (Proxy proxy : proxies.getProxies()) { - if (proxy.getId() != null) { - merged.putIfAbsent(proxy.getId(), proxy); - } else { - merged.put("__null_" + nullIndex++, proxy); - } - } - } - return new Proxies(new ArrayList<>(merged.values())); + return new Proxies(mergeById(this.proxies, proxies == null ? null : proxies.getProxies(), Proxy::getId)); } } diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java index 2648c4b05e4..15fcdf2ac52 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenSettingsTest.java @@ -911,6 +911,190 @@ void replacesElementsWithMatchingIds() { .hasFieldOrPropertyWithValue("username", "foo") .hasFieldOrPropertyWithValue("password", null); } + + @Test + void keepsIdLessEntriesFromBothSides() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + + + first-repo + https://first.example.com/maven2 + + + + + + + https://first.example.com/maven2 + central + + + + + first + secret + + + + + first-proxy.example.com + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + + + second-repo + https://second.example.com/maven2 + + + + + + + https://second.example.com/maven2 + * + + + + + second + secret + + + + + second-proxy.example.com + + + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getProfiles().getProfiles()) + .extracting(p -> p.getRepositories().getRepositories().getFirst().getId()) + .containsExactly("first-repo", "second-repo"); + assertThat(mergedSettings.getMirrors().getMirrors()) + .extracting(MavenSettings.Mirror::getUrl) + .containsExactly("https://first.example.com/maven2", "https://second.example.com/maven2"); + assertThat(mergedSettings.getServers().getServers()) + .extracting(MavenSettings.Server::getUsername) + .containsExactly("first", "second"); + assertThat(mergedSettings.getProxies().getProxies()) + .extracting(MavenSettings.Proxy::getHost) + .containsExactly("first-proxy.example.com", "second-proxy.example.com"); + } + + @Test + void collapsesDuplicateIdsWithinOneSideToTheLastValue() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + duplicate + https://stale.example.com/maven2 + central + + + tail + https://tail.example.com/maven2 + * + + + duplicate + https://effective.example.com/maven2 + central + + + + + duplicate + stale + secret + + + duplicate + effective + secret + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + /tmp/other-repo + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getMirrors().getMirrors()) + .extracting(MavenSettings.Mirror::getUrl) + .containsExactly("https://effective.example.com/maven2", "https://tail.example.com/maven2"); + assertThat(mergedSettings.getServers().getServers()) + .extracting(MavenSettings.Server::getUsername) + .containsExactly("effective"); + } + + @Test + void replacesProxiesWithMatchingIds() { + var first = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + corp + preferred.example.com + + + + """ + ), ctx); + var second = MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), + //language=xml + """ + + + + corp + other.example.com + + + backup + backup.example.com + + + + """ + ), ctx); + + var mergedSettings = first.merge(second); + + assertThat(mergedSettings.getProxies().getProxies()) + .extracting(MavenSettings.Proxy::getHost) + .containsExactly("preferred.example.com", "backup.example.com"); + } } /** From e51559180bac1194f9cffc1f26bf2e386df43f22 Mon Sep 17 00:00:00 2001 From: Pierre Delagrave Date: Mon, 6 Jul 2026 22:43:17 -0400 Subject: [PATCH 3/4] Memoize effective Maven settings and mirrors per execution context Recipes construct a `MavenPomDownloader` per visited tag, and merging plus value-comparing two settings graphs on every construction is measurable across large runs. Cache the merged settings and the mapped mirrors per parsed-settings instance, invalidated when settings or mirrors are replaced on the context. --- .../maven/MavenExecutionContextView.java | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java index c57d328a09a..5f3c43c6826 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenExecutionContextView.java @@ -15,6 +15,8 @@ */ package org.openrewrite.maven; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import org.jspecify.annotations.Nullable; import org.openrewrite.DelegatingExecutionContext; import org.openrewrite.ExecutionContext; @@ -52,6 +54,8 @@ public class MavenExecutionContextView extends DelegatingExecutionContext { private static final String MAVEN_REPOSITORIES = "org.openrewrite.maven.repos"; private static final String MAVEN_PINNED_SNAPSHOT_VERSIONS = "org.openrewrite.maven.pinnedSnapshotVersions"; private static final String MAVEN_POM_CACHE = "org.openrewrite.maven.pomCache"; + private static final String MAVEN_EFFECTIVE_SETTINGS_CACHE = "org.openrewrite.maven.effectiveSettingsCache"; + private static final String MAVEN_MIRRORS_CACHE = "org.openrewrite.maven.mirrorsCache"; private static final String MAVEN_ARTIFACT_CACHE = "org.openrewrite.maven.artifactCache"; private static final String MAVEN_RESOLUTION_LISTENER = "org.openrewrite.maven.resolutionListener"; private static final String MAVEN_RESOLUTION_TIME = "org.openrewrite.maven.resolutionTime"; @@ -103,6 +107,7 @@ public ResolutionEventListener getResolutionListener() { public MavenExecutionContextView setMirrors(@Nullable Collection mirrors) { putMessage(MAVEN_MIRRORS, mirrors); + putMessage(MAVEN_MIRRORS_CACHE, null); return this; } @@ -112,15 +117,20 @@ public Collection getMirrors() { /** * Get mirrors set on this execution context, unless overridden by a supplied maven settings file. + * Supplied settings take precedence: when they differ in value from the settings on this context, + * their mirrors replace (not merge with) the context's mirror list, including a list installed + * explicitly via {@link #setMirrors(Collection)}. * * @param mavenSettings The maven settings defining mirrors to use, if any. * @return The mirrors to use for dependency resolution. */ public Collection getMirrors(@Nullable MavenSettings mavenSettings) { - if (mavenSettings != null && !Objects.equals(mavenSettings, getSettings())) { - return mapMirrors(mavenSettings); + if (mavenSettings == null) { + return getMirrors(); } - return getMirrors(); + return this.>getIdentityCache(MAVEN_MIRRORS_CACHE) + .get(mavenSettings, settings -> + !Objects.equals(settings, getSettings()) ? mapMirrors(settings) : getMirrors()); } public MavenExecutionContextView setCredentials(Collection credentials) { @@ -312,6 +322,8 @@ public MavenExecutionContextView setMavenSettings(@Nullable MavenSettings settin } putMessage(MAVEN_SETTINGS, settings); + putMessage(MAVEN_EFFECTIVE_SETTINGS_CACHE, null); + putMessage(MAVEN_MIRRORS_CACHE, null); List effectiveActiveProfiles = mapActiveProfiles(settings, activeProfiles); setActiveProfiles(effectiveActiveProfiles); setCredentials(mapCredentials(settings)); @@ -332,13 +344,26 @@ public MavenExecutionContextView setMavenSettings(@Nullable MavenSettings settin * */ public @Nullable MavenSettings effectiveSettings(MavenResolutionResult mrr) { - MavenSettings effectiveSettings = getMessage(MAVEN_SETTINGS); - if (effectiveSettings == null) { - effectiveSettings = mrr.getMavenSettings(); - } else { - effectiveSettings = effectiveSettings.merge(mrr.getMavenSettings()); + MavenSettings contextSettings = getMessage(MAVEN_SETTINGS); + MavenSettings parsedSettings = mrr.getMavenSettings(); + if (contextSettings == null) { + return parsedSettings; } - return effectiveSettings; + if (parsedSettings == null) { + return contextSettings; + } + // Recipes construct a downloader per visited tag, so memoize the merge per parsed-settings + // instance; returning a stable instance also lets getMirrors(MavenSettings) memoize. + return this.getIdentityCache(MAVEN_EFFECTIVE_SETTINGS_CACHE) + .get(parsedSettings, contextSettings::merge); + } + + private Cache getIdentityCache(String key) { + // Weak keys give identity comparison and let entries die with the settings instances they + // memoize for, so a long run over many repositories does not pin every parsed settings graph. + //noinspection unchecked + return (Cache) getMessages().computeIfAbsent(key, + k -> Caffeine.newBuilder().weakKeys().build()); } private static List mapActiveProfiles(MavenSettings settings, String... activeProfiles) { From 57edecb6952d62ed93ea3f2d54649c6e1bd325af Mon Sep 17 00:00:00 2001 From: Pierre Delagrave Date: Mon, 6 Jul 2026 16:37:01 -0400 Subject: [PATCH 4/4] Add tests pinning that a mirror of Maven Central redirects all resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organizations use `mirrorOf=central` (or `*`) to keep resolution on an internal repository manager, often with public Maven Central blocked at the network level. These tests pin the guarantee that a mirror carried in the LST's Maven settings redirects all metadata resolution to the mirror — including the implicitly added Maven Central and when run-time execution context settings carry no mirror — and that `repo.maven.apache.org` is never contacted. --- .../maven/MavenCentralMirrorTest.java | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java new file mode 100644 index 00000000000..cf3f6305ef6 --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/MavenCentralMirrorTest.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * 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 + *

+ * https://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. + */ +package org.openrewrite.maven; + +import org.intellij.lang.annotations.Language; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openrewrite.ExecutionContext; +import org.openrewrite.HttpSenderExecutionContextView; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.Parser; +import org.openrewrite.Recipe; +import org.openrewrite.ipc.http.HttpSender; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.table.MavenMetadataFailures; +import org.openrewrite.maven.trait.MavenDependency; +import org.openrewrite.maven.tree.GroupArtifact; +import org.openrewrite.maven.tree.MavenMetadata; +import org.openrewrite.maven.tree.MavenRepository; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.semver.Semver; +import org.openrewrite.semver.VersionComparator; +import org.openrewrite.xml.tree.Xml; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the guarantee that a {@code central} (or {@code *}) mirror carried in an LST's + * {@link MavenResolutionResult#getMavenSettings()} redirects all resolution traffic to the mirror, + * including the implicitly added Maven Central, and that {@code repo.maven.apache.org} is never contacted. + *

+ * In particular this guards the behavior fixed in #4956: + * Maven settings supplied on the {@link ExecutionContext} at recipe run time must be merged with, not + * replace, the settings captured in the LST at parse time. + */ +class MavenCentralMirrorTest { + + private static final String MIRROR_URL = "https://artifacts.example.com/maven2"; + + @Language("xml") + private static final String POM = """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + """; + + @Language("xml") + private static final String METADATA = """ + + org.example.hermetic + example-lib + + 1.1 + 1.1 + + 1.0 + 1.1 + + + + """; + + /** + * Serves canned metadata for any {@code maven-metadata.xml} request and records every requested URL, + * so tests can assert on which hosts resolution actually contacted without any real network access. + */ + static class RecordingHttpSender implements HttpSender { + final List requestedUrls = new CopyOnWriteArrayList<>(); + + @Override + public Response send(Request request) { + String url = request.getUrl().toString(); + requestedUrls.add(url); + byte[] body = url.endsWith("maven-metadata.xml") ? METADATA.getBytes(StandardCharsets.UTF_8) : new byte[0]; + return new Response(200, new ByteArrayInputStream(body), () -> { + }); + } + } + + @ParameterizedTest + @ValueSource(strings = {"central", "*"}) + void lstMirrorHonoredWhenContextSettingsHaveNoMirror(String mirrorOf) throws MavenDownloadingException { + // At recipe run time settings are often supplied on the execution context, e.g. to carry + // credentials. Their presence must not discard the mirror captured in the LST (#4956). + MavenResolutionResult mrr = parsePomWith(settingsWithMirror(mirrorOf)); + ExecutionContext ctx = runContext(); + MavenExecutionContextView.view(ctx).setMavenSettings(settingsWithServerCredentialsOnly()); + + String newerVersion = findNewerVersion(mrr, ctx); + + assertThat(newerVersion).isEqualTo("1.1"); + assertOnlyMirrorContacted(ctx); + } + + @Test + void lstMirrorHonoredWhenContextHasNoSettings() throws MavenDownloadingException { + MavenResolutionResult mrr = parsePomWith(settingsWithMirror("central")); + ExecutionContext ctx = runContext(); + + String newerVersion = findNewerVersion(mrr, ctx); + + assertThat(newerVersion).isEqualTo("1.1"); + assertOnlyMirrorContacted(ctx); + } + + @Test + void implicitlyAddedCentralRedirectedToMirror() throws MavenDownloadingException { + // Even when no repository is supplied at all, the downloader implicitly adds Maven Central; + // that injected repository must also come out redirected to the mirror. + ExecutionContext ctx = runContext(); + MavenPomDownloader downloader = new MavenPomDownloader(emptyMap(), ctx, settingsWithMirror("central"), null); + + MavenRepository normalizedCentral = downloader.normalizeRepository( + MavenRepository.MAVEN_CENTRAL, MavenExecutionContextView.view(ctx), null); + assertThat(normalizedCentral).isNotNull(); + assertThat(normalizedCentral.getUri()).startsWith(MIRROR_URL); + assertThat(normalizedCentral.getId()).isEqualTo("internal-mirror"); + + MavenMetadata metadata = downloader.downloadMetadata( + new GroupArtifact("org.example.hermetic", "example-lib"), null, emptyList()); + + assertThat(metadata.getVersioning().getVersions()).contains("1.0", "1.1"); + assertOnlyMirrorContacted(ctx); + } + + private static MavenSettings settingsWithMirror(String mirrorOf) { + //language=xml + return requireNonNull(MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), """ + + + + internal-mirror + %s + %s + + + + """.formatted(MIRROR_URL, mirrorOf)), throwingContext())); + } + + private static MavenSettings settingsWithServerCredentialsOnly() { + //language=xml + return requireNonNull(MavenSettings.parse(Parser.Input.fromString(Path.of("settings.xml"), """ + + + + internal-mirror + ci + secret + + + + """), throwingContext())); + } + + /** + * Parses a pom with the given settings on the parsing context, as an LST producer would, and returns + * the resolution result marker carrying those settings. + */ + private static MavenResolutionResult parsePomWith(MavenSettings settings) { + ExecutionContext parseCtx = parseContext(settings); + Xml.Document pom = (Xml.Document) MavenParser.builder().build() + .parse(parseCtx, POM) + .findFirst() + .orElseThrow(); + RecordingHttpSender parseSender = (RecordingHttpSender) + HttpSenderExecutionContextView.view(parseCtx).getHttpSender(); + assertThat(parseSender.requestedUrls).isEmpty(); + + MavenResolutionResult mrr = pom.getMarkers().findFirst(MavenResolutionResult.class).orElseThrow(); + assertThat(requireNonNull(mrr.getMavenSettings()).getMirrors()).isNotNull(); + return mrr; + } + + private static ExecutionContext parseContext(MavenSettings settings) { + ExecutionContext parseCtx = runContext(); + MavenExecutionContextView.view(parseCtx).setMavenSettings(settings); + return parseCtx; + } + + private static ExecutionContext runContext() { + ExecutionContext ctx = throwingContext(); + HttpSenderExecutionContextView.view(ctx).setHttpSender(new RecordingHttpSender()); + // The local repository is consulted through the filesystem, invisible to the recording + // sender; disable it so the developer's ~/.m2 cannot influence results. + MavenExecutionContextView.view(ctx).setAddLocalRepository(false); + return ctx; + } + + private static InMemoryExecutionContext throwingContext() { + return new InMemoryExecutionContext(t -> { + throw new RuntimeException(t); + }); + } + + private static @Nullable String findNewerVersion(MavenResolutionResult mrr, ExecutionContext ctx) + throws MavenDownloadingException { + VersionComparator latestRelease = requireNonNull(Semver.validate("latest.release", null).getValue()); + return MavenDependency.findNewerVersion( + "org.example.hermetic", "example-lib", "1.0", mrr, + new MavenMetadataFailures(Recipe.noop()), latestRelease, ctx); + } + + private static void assertOnlyMirrorContacted(ExecutionContext ctx) { + RecordingHttpSender sender = (RecordingHttpSender) + HttpSenderExecutionContextView.view(ctx).getHttpSender(); + assertThat(sender.requestedUrls) + .as("all resolution traffic must be redirected to the mirror") + .allSatisfy(url -> assertThat(url).startsWith(MIRROR_URL)) + .as("at least one metadata request must have reached the mirror") + .anySatisfy(url -> assertThat(url).endsWith("maven-metadata.xml")); + } +}