diff --git a/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java b/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
index 4a61ba7608d2..d8df37230f6a 100644
--- a/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
+++ b/maven-compat/src/main/java/org/apache/maven/artifact/manager/DefaultWagonManager.java
@@ -75,7 +75,10 @@ public AuthenticationInfo getAuthenticationInfo(String id) {
if (servers != null) {
for (Server server : servers) {
- if (id.equalsIgnoreCase(server.getId())) {
+ // Server ids are matched exactly, consistent with
+ // LegacyRepositorySystem.injectAuthentication and the resolver's
+ // authentication selector.
+ if (id.equals(server.getId())) {
SettingsDecryptionResult result =
settingsDecrypter.decrypt(new DefaultSettingsDecryptionRequest(server));
server = result.getServer();
diff --git a/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
index 17f3ab21f1f6..3be3fc027830 100644
--- a/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
+++ b/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
@@ -30,10 +30,12 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import org.apache.maven.RepositoryUtils;
import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager;
+import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.bridge.MavenRepositorySystem;
import org.apache.maven.eventspy.internal.EventSpyDispatcher;
import org.apache.maven.execution.MavenExecutionRequest;
@@ -56,6 +58,7 @@
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.collection.VersionFilterBuilder;
+import org.eclipse.aether.repository.AuthenticationSelector;
import org.eclipse.aether.repository.LocalRepository;
import org.eclipse.aether.repository.LocalRepositoryManager;
import org.eclipse.aether.repository.RepositoryPolicy;
@@ -141,6 +144,24 @@ public class DefaultRepositorySystemSessionFactory implements RepositorySystemSe
public static final String MAVEN_RESOLVER_DEPENDENCY_MANAGER_TRANSITIVITY =
"maven.resolver.dependencyManagerTransitivity";
+ /**
+ * User property selecting how server credentials configured in settings are scoped to repositories:
+ *
> declaredRepositoryOrigins = new HashMap<>();
+
DefaultMirrorSelector mirrorSelector = new DefaultMirrorSelector();
for (Mirror mirror : request.getMirrors()) {
mirrorSelector.add(
@@ -287,8 +312,17 @@ public RepositorySystemSession.SessionBuilder newRepositorySessionBuilder(MavenE
mirror.isBlocked(),
mirror.getMirrorOf(),
mirror.getMirrorOfLayouts());
+ OriginBoundAuthenticationSelector.addOrigin(declaredRepositoryOrigins, mirror.getId(), mirror.getUrl());
}
mainSessionBuilder.setMirrorSelector(mirrorSelector);
+ for (ArtifactRepository repository : request.getRemoteRepositories()) {
+ OriginBoundAuthenticationSelector.addOrigin(
+ declaredRepositoryOrigins, repository.getId(), repository.getUrl());
+ }
+ for (ArtifactRepository repository : request.getPluginArtifactRepositories()) {
+ OriginBoundAuthenticationSelector.addOrigin(
+ declaredRepositoryOrigins, repository.getId(), repository.getUrl());
+ }
DefaultProxySelector proxySelector = new DefaultProxySelector();
for (Proxy proxy : decrypted.getProxies()) {
@@ -393,7 +427,11 @@ public RepositorySystemSession.SessionBuilder newRepositorySessionBuilder(MavenE
configProps.put("aether.transport.wagon.perms.fileMode." + server.getId(), server.getFilePermissions());
configProps.put("aether.transport.wagon.perms.dirMode." + server.getId(), server.getDirectoryPermissions());
}
- mainSessionBuilder.setAuthenticationSelector(authSelector);
+ String credentialScope = ConfigUtils.getString(
+ configProps, OriginBoundAuthenticationSelector.SCOPE_ORIGIN, MAVEN_REPOSITORY_CREDENTIAL_SCOPE);
+ AuthenticationSelector effectiveAuthSelector = OriginBoundAuthenticationSelector.wrap(
+ authSelector, credentialScope, declaredRepositoryOrigins, logger);
+ mainSessionBuilder.setAuthenticationSelector(effectiveAuthSelector);
Object transport = configProps.getOrDefault(MAVEN_RESOLVER_TRANSPORT_KEY, MAVEN_RESOLVER_TRANSPORT_DEFAULT);
if (MAVEN_RESOLVER_TRANSPORT_DEFAULT.equals(transport)) {
diff --git a/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java b/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java
new file mode 100644
index 000000000000..1898bf9666af
--- /dev/null
+++ b/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.maven.internal.aether;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.codehaus.plexus.logging.Logger;
+import org.eclipse.aether.repository.Authentication;
+import org.eclipse.aether.repository.AuthenticationSelector;
+import org.eclipse.aether.repository.RemoteRepository;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * An {@link AuthenticationSelector} that scopes server credentials to the origin (protocol, host and
+ * port) of the repository or mirror the operator declared for the same server id.
+ *
+ * A repository's id and its origin are independent: this selector serves a server id's credentials
+ * only to a repository whose origin matches one the operator declared for that id, in settings or on
+ * the command line. Ids with no operator-declared origin keep the previous behaviour unless
+ * {@code strict} scope is requested, and a warning naming the target origin is emitted once per
+ * id/origin pair.
+ *
+ * @see DefaultRepositorySystemSessionFactory#MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ */
+class OriginBoundAuthenticationSelector implements AuthenticationSelector {
+ /**
+ * Credentials are bound to operator-declared origins; ids without a declared origin keep legacy
+ * behavior, with a warning.
+ */
+ static final String SCOPE_ORIGIN = "origin";
+
+ /**
+ * Credentials are bound to operator-declared origins; ids without a declared origin get no
+ * credentials.
+ */
+ static final String SCOPE_STRICT = "strict";
+
+ /**
+ * Legacy behavior: credentials are matched by server id only.
+ */
+ static final String SCOPE_ID = "id";
+
+ private final AuthenticationSelector delegate;
+
+ private final Map> declaredOrigins;
+
+ private final boolean strict;
+
+ private final Logger logger;
+
+ private final Set reported = ConcurrentHashMap.newKeySet();
+
+ private OriginBoundAuthenticationSelector(
+ AuthenticationSelector delegate, Map> declaredOrigins, boolean strict, Logger logger) {
+ this.delegate = requireNonNull(delegate, "delegate");
+ this.declaredOrigins = requireNonNull(declaredOrigins, "declaredOrigins");
+ this.strict = strict;
+ this.logger = requireNonNull(logger, "logger");
+ }
+
+ /**
+ * Wraps the given selector according to the requested credential scope.
+ *
+ * @param delegate the selector holding the actual credentials, keyed by server id
+ * @param credentialScope one of {@link #SCOPE_ORIGIN}, {@link #SCOPE_STRICT} or {@link #SCOPE_ID}
+ * @param declaredOrigins origins of operator-declared repositories and mirrors, keyed by id
+ * @param logger logger used to report id/origin mismatches
+ * @return the delegate itself for {@link #SCOPE_ID}, an origin-bound wrapper otherwise
+ */
+ static AuthenticationSelector wrap(
+ AuthenticationSelector delegate,
+ String credentialScope,
+ Map> declaredOrigins,
+ Logger logger) {
+ if (SCOPE_ID.equals(credentialScope)) {
+ return delegate;
+ } else if (SCOPE_ORIGIN.equals(credentialScope) || SCOPE_STRICT.equals(credentialScope)) {
+ return new OriginBoundAuthenticationSelector(
+ delegate, declaredOrigins, SCOPE_STRICT.equals(credentialScope), logger);
+ } else {
+ throw new IllegalArgumentException("Unknown value '" + credentialScope + "' for "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + ". Supported values are: " + SCOPE_ORIGIN + ", " + SCOPE_STRICT + ", " + SCOPE_ID);
+ }
+ }
+
+ /**
+ * Records the origin of an operator-declared repository or mirror for the given id. URLs without a
+ * parseable server authority (for example {@code file:} URLs) are ignored.
+ */
+ static void addOrigin(Map> declaredOrigins, String id, String url) {
+ String origin = originOf(url);
+ if (id != null && origin != null) {
+ declaredOrigins.computeIfAbsent(id, k -> new HashSet<>()).add(origin);
+ }
+ }
+
+ @Override
+ public Authentication getAuthentication(RemoteRepository repository) {
+ Authentication auth = delegate.getAuthentication(repository);
+ if (auth == null) {
+ return null;
+ }
+ String id = repository.getId();
+ String origin = originOf(repository.getUrl());
+ Set origins = declaredOrigins.get(id);
+ if (origins != null && !origins.isEmpty()) {
+ if (origin != null && origins.contains(origin)) {
+ return auth;
+ }
+ warnOnce(
+ id,
+ origin,
+ "Not using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ": the repository or mirror declared for this id resides at " + origins
+ + ". Set "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + "="
+ + SCOPE_ID + " to restore legacy id-only credential matching.");
+ return null;
+ }
+ if (strict) {
+ warnOnce(
+ id,
+ origin,
+ "Not using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ": no repository or mirror with this id is declared in settings or on the command"
+ + " line, and " + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + "=" + SCOPE_STRICT + " is in effect.");
+ return null;
+ }
+ warnOnce(
+ id,
+ origin,
+ "Using credentials of server '" + id + "' for repository " + repository.getUrl()
+ + ", although no repository or mirror with this id is declared in settings or on the"
+ + " command line. Set "
+ + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE
+ + "=" + SCOPE_STRICT + " to refuse such credential use.");
+ return auth;
+ }
+
+ private void warnOnce(String id, String origin, String message) {
+ if (reported.add(id + "->" + origin)) {
+ logger.warn(message);
+ }
+ }
+
+ /**
+ * Returns the normalized origin ({@code protocol://host[:port]}, lower-cased, default http/https
+ * ports elided) of the given URL, or {@code null} if the URL has no parseable server authority.
+ */
+ static String originOf(String url) {
+ if (url == null) {
+ return null;
+ }
+ try {
+ URI uri = new URI(url).parseServerAuthority();
+ String scheme = uri.getScheme();
+ String host = uri.getHost();
+ if (scheme == null || host == null) {
+ return null;
+ }
+ scheme = scheme.toLowerCase(Locale.ROOT);
+ host = host.toLowerCase(Locale.ROOT);
+ int port = uri.getPort();
+ if ((port == 80 && "http".equals(scheme)) || (port == 443 && "https".equals(scheme))) {
+ port = -1;
+ }
+ return port >= 0 ? scheme + "://" + host + ":" + port : scheme + "://" + host;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+}
diff --git a/maven-core/src/main/java/org/apache/maven/project/artifact/MavenMetadataSource.java b/maven-core/src/main/java/org/apache/maven/project/artifact/MavenMetadataSource.java
index 1a127e33a200..aed19166e2a0 100644
--- a/maven-core/src/main/java/org/apache/maven/project/artifact/MavenMetadataSource.java
+++ b/maven-core/src/main/java/org/apache/maven/project/artifact/MavenMetadataSource.java
@@ -601,16 +601,19 @@ private ProjectRelocation retrieveRelocatedProject(Artifact artifact, MetadataRe
if (relocation != null) {
if (relocation.getGroupId() != null) {
+ requireValidCoordinateComponent(relocation.getGroupId(), "groupId", artifact);
artifact.setGroupId(relocation.getGroupId());
relocatedArtifact = artifact;
project.setGroupId(relocation.getGroupId());
}
if (relocation.getArtifactId() != null) {
+ requireValidCoordinateComponent(relocation.getArtifactId(), "artifactId", artifact);
artifact.setArtifactId(relocation.getArtifactId());
relocatedArtifact = artifact;
project.setArtifactId(relocation.getArtifactId());
}
if (relocation.getVersion() != null) {
+ requireValidCoordinateComponent(relocation.getVersion(), "version", artifact);
// note: see MNG-3454. This causes a problem, but fixing it may break more.
artifact.setVersionRange(VersionRange.createFromVersion(relocation.getVersion()));
relocatedArtifact = artifact;
@@ -666,6 +669,34 @@ private ProjectRelocation retrieveRelocatedProject(Artifact artifact, MetadataRe
return rel;
}
+ /**
+ * Checks that a relocation coordinate component is usable as an artifact coordinate component before it
+ * is applied to the artifact and project. A component outside the coordinate character set is rejected so
+ * that only well-formed coordinates enter resolution.
+ */
+ private static void requireValidCoordinateComponent(String value, String component, Artifact artifact)
+ throws ArtifactMetadataRetrievalException {
+ if (isInvalidCoordinateComponent(value)) {
+ throw new ArtifactMetadataRetrievalException(
+ "Invalid relocation " + component + " '" + value + "' for " + artifact.getId()
+ + ": not a valid artifact coordinate component",
+ null,
+ artifact);
+ }
+ }
+
+ private static boolean isInvalidCoordinateComponent(String value) {
+ if ("..".equals(value) || value.contains("/") || value.contains("\\") || value.contains(":")) {
+ return true;
+ }
+ for (int i = 0; i < value.length(); i++) {
+ if (Character.isISOControl(value.charAt(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private ModelProblem hasMissingParentPom(ProjectBuildingException e) {
if (e.getCause() instanceof ModelBuildingException) {
ModelBuildingException mbe = (ModelBuildingException) e.getCause();
diff --git a/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java b/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java
new file mode 100644
index 000000000000..d62c1498456a
--- /dev/null
+++ b/maven-core/src/test/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelectorTest.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.maven.internal.aether;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.codehaus.plexus.logging.Logger;
+import org.codehaus.plexus.logging.console.ConsoleLogger;
+import org.eclipse.aether.repository.AuthenticationSelector;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.util.repository.AuthenticationBuilder;
+import org.eclipse.aether.util.repository.DefaultAuthenticationSelector;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * UT for {@link OriginBoundAuthenticationSelector}.
+ */
+class OriginBoundAuthenticationSelectorTest {
+
+ private static final Logger LOGGER = new ConsoleLogger(Logger.LEVEL_DEBUG, "test");
+
+ private static AuthenticationSelector serverCredentials(String... ids) {
+ DefaultAuthenticationSelector selector = new DefaultAuthenticationSelector();
+ for (String id : ids) {
+ selector.add(
+ id,
+ new AuthenticationBuilder()
+ .addUsername("user")
+ .addPassword("pass")
+ .build());
+ }
+ return selector;
+ }
+
+ private static Map> declared(String id, String url) {
+ Map> origins = new HashMap<>();
+ OriginBoundAuthenticationSelector.addOrigin(origins, id, url);
+ return origins;
+ }
+
+ private static RemoteRepository repo(String id, String url) {
+ return new RemoteRepository.Builder(id, "default", url).build();
+ }
+
+ @Test
+ void credentialsServedForDeclaredOrigin() {
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("releases"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertNotNull(selector.getAuthentication(repo("releases", "https://repo.example.org/releases/")));
+ }
+
+ @Test
+ void authenticationScopedToDeclaredOrigin() {
+ // credentials are scoped to the declared origin, so a different-origin
+ // repository with the same id is not served
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("releases"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/")));
+ // an unparseable URL on a bound id fails closed as well
+ assertNull(selector.getAuthentication(repo("releases", "notaurl")));
+ }
+
+ @Test
+ void undeclaredIdKeepsLegacyBehaviorInOriginScope() {
+ // e.g. a pure deployment server whose URL only exists in the project's distributionManagement
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("deploy-server"),
+ OriginBoundAuthenticationSelector.SCOPE_ORIGIN,
+ new HashMap<>(),
+ LOGGER);
+
+ assertNotNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/")));
+ }
+
+ @Test
+ void undeclaredIdRefusedInStrictScope() {
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ serverCredentials("deploy-server"),
+ OriginBoundAuthenticationSelector.SCOPE_STRICT,
+ new HashMap<>(),
+ LOGGER);
+
+ assertNull(selector.getAuthentication(repo("deploy-server", "https://deploy.example.org/releases/")));
+ }
+
+ @Test
+ void idScopeReturnsUnwrappedDelegate() {
+ AuthenticationSelector delegate = serverCredentials("releases");
+ AuthenticationSelector selector = OriginBoundAuthenticationSelector.wrap(
+ delegate,
+ OriginBoundAuthenticationSelector.SCOPE_ID,
+ declared("releases", "https://repo.example.org/releases/"),
+ LOGGER);
+
+ assertSame(delegate, selector);
+ assertNotNull(selector.getAuthentication(repo("releases", "https://other.example.org/m2/")));
+ }
+
+ @Test
+ void unknownScopeIsRejected() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OriginBoundAuthenticationSelector.wrap(serverCredentials(), "bogus", new HashMap<>(), LOGGER));
+ }
+
+ @Test
+ void originsAreNormalized() {
+ assertEquals(
+ OriginBoundAuthenticationSelector.originOf("https://repo.example.org/releases/"),
+ OriginBoundAuthenticationSelector.originOf("HTTPS://Repo.Example.Org:443/other/path"));
+ assertEquals(
+ OriginBoundAuthenticationSelector.originOf("http://repo.example.org:80/"),
+ OriginBoundAuthenticationSelector.originOf("http://repo.example.org/releases/"));
+ assertNull(OriginBoundAuthenticationSelector.originOf("file:/tmp/repo"));
+ assertNull(OriginBoundAuthenticationSelector.originOf(null));
+ }
+}
diff --git a/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java b/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java
new file mode 100644
index 000000000000..9fd48d9f7e01
--- /dev/null
+++ b/maven-core/src/test/java/org/apache/maven/project/artifact/MavenMetadataSourceRelocationTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.maven.project.artifact;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.artifact.DefaultArtifact;
+import org.apache.maven.artifact.factory.ArtifactFactory;
+import org.apache.maven.artifact.handler.DefaultArtifactHandler;
+import org.apache.maven.artifact.metadata.ArtifactMetadataRetrievalException;
+import org.apache.maven.artifact.metadata.ResolutionGroup;
+import org.apache.maven.artifact.repository.ArtifactRepository;
+import org.apache.maven.artifact.repository.metadata.RepositoryMetadataManager;
+import org.apache.maven.bridge.MavenRepositorySystem;
+import org.apache.maven.model.DistributionManagement;
+import org.apache.maven.model.Relocation;
+import org.apache.maven.plugin.LegacySupport;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.project.ProjectBuilder;
+import org.apache.maven.project.ProjectBuildingRequest;
+import org.apache.maven.project.ProjectBuildingResult;
+import org.apache.maven.repository.legacy.metadata.DefaultMetadataResolutionRequest;
+import org.apache.maven.repository.legacy.metadata.MetadataResolutionRequest;
+import org.eclipse.aether.RepositorySystemSession;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that a relocation read from a resolved project's distribution management is validated before its
+ * components are applied to the artifact and project being resolved, exercising the real
+ * {@link MavenMetadataSource#retrieve(MetadataResolutionRequest)} code path with mocked collaborators (no
+ * on-disk artifact resolution, so this test does not depend on, or share, any module-level local repository).
+ */
+class MavenMetadataSourceRelocationTest {
+
+ private MavenMetadataSource newSource(ProjectBuilder projectBuilder) throws Exception {
+ MavenMetadataSource source = new MavenMetadataSource();
+
+ ArtifactFactory artifactFactory = mock(ArtifactFactory.class);
+ when(artifactFactory.createProjectArtifact(any(), any(), any(), any()))
+ .thenAnswer(invocation -> new DefaultArtifact(
+ (String) invocation.getArgument(0),
+ (String) invocation.getArgument(1),
+ (String) invocation.getArgument(2),
+ (String) invocation.getArgument(3),
+ "pom",
+ null,
+ new DefaultArtifactHandler("pom")));
+
+ LegacySupport legacySupport = mock(LegacySupport.class);
+ RepositorySystemSession repositorySession = mock(RepositorySystemSession.class);
+ when(legacySupport.getRepositorySession()).thenReturn(repositorySession);
+ when(legacySupport.getSession()).thenReturn(null);
+
+ setField(source, "artifactFactory", artifactFactory);
+ setField(source, "repositorySystem", mock(MavenRepositorySystem.class));
+ setField(source, "repositoryMetadataManager", mock(RepositoryMetadataManager.class));
+ setField(source, "projectBuilderProvider", (javax.inject.Provider) () -> projectBuilder);
+ setField(source, "cache", mock(MavenMetadataCache.class));
+ setField(source, "legacySupport", legacySupport);
+
+ return source;
+ }
+
+ private static void setField(Object target, String name, Object value) throws Exception {
+ Field field = MavenMetadataSource.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ private static Artifact newArtifact(String groupId, String artifactId, String version) {
+ return new DefaultArtifact(
+ groupId, artifactId, version, Artifact.SCOPE_COMPILE, "pom", null, new DefaultArtifactHandler("pom"));
+ }
+
+ private static MavenProject newProject(String groupId, String artifactId, String version, Relocation relocation) {
+ MavenProject project = new MavenProject();
+ project.setGroupId(groupId);
+ project.setArtifactId(artifactId);
+ project.setVersion(version);
+ if (relocation != null) {
+ DistributionManagement distMgmt = new DistributionManagement();
+ distMgmt.setRelocation(relocation);
+ project.setDistributionManagement(distMgmt);
+ }
+ return project;
+ }
+
+ private static MetadataResolutionRequest newRequest(Artifact artifact) {
+ MetadataResolutionRequest request = new DefaultMetadataResolutionRequest();
+ request.setArtifact(artifact);
+ request.setLocalRepository(mock(ArtifactRepository.class));
+ request.setRemoteRepositories(Collections.emptyList());
+ return request;
+ }
+
+ @Test
+ void testRelocationInvalidArtifactIdIsRejected() throws Exception {
+ Relocation relocation = new Relocation();
+ relocation.setArtifactId("a/b");
+
+ MavenProject relocatingProject = newProject("group", "original", "1.0", relocation);
+ MavenProject finalProject = newProject("group", "a/b", "1.0", null);
+
+ ProjectBuildingResult first = mock(ProjectBuildingResult.class);
+ when(first.getProject()).thenReturn(relocatingProject);
+ ProjectBuildingResult second = mock(ProjectBuildingResult.class);
+ when(second.getProject()).thenReturn(finalProject);
+
+ ProjectBuilder projectBuilder = mock(ProjectBuilder.class);
+ when(projectBuilder.build(any(Artifact.class), any(ProjectBuildingRequest.class)))
+ .thenReturn(first, second);
+
+ MavenMetadataSource source = newSource(projectBuilder);
+ Artifact artifact = newArtifact("group", "original", "1.0");
+ MetadataResolutionRequest request = newRequest(artifact);
+
+ ArtifactMetadataRetrievalException exception =
+ assertThrows(ArtifactMetadataRetrievalException.class, () -> source.retrieve(request));
+ assertEquals(true, exception.getMessage().contains("a/b"));
+ assertEquals(true, exception.getMessage().contains("artifactId"));
+ }
+
+ @Test
+ void testWellFormedRelocationIsApplied() throws Exception {
+ Relocation relocation = new Relocation();
+ relocation.setGroupId("group.moved");
+ relocation.setArtifactId("artifact-moved");
+ relocation.setVersion("2.0");
+
+ MavenProject relocatingProject = newProject("group", "original", "1.0", relocation);
+ MavenProject finalProject = newProject("group.moved", "artifact-moved", "2.0", null);
+
+ ProjectBuildingResult first = mock(ProjectBuildingResult.class);
+ when(first.getProject()).thenReturn(relocatingProject);
+ ProjectBuildingResult second = mock(ProjectBuildingResult.class);
+ when(second.getProject()).thenReturn(finalProject);
+
+ ProjectBuilder projectBuilder = mock(ProjectBuilder.class);
+ when(projectBuilder.build(any(Artifact.class), any(ProjectBuildingRequest.class)))
+ .thenReturn(first, second);
+
+ MavenMetadataSource source = newSource(projectBuilder);
+ Artifact artifact = newArtifact("group", "original", "1.0");
+ MetadataResolutionRequest request = newRequest(artifact);
+
+ ResolutionGroup result = source.retrieve(request);
+
+ assertEquals("group.moved", artifact.getGroupId());
+ assertEquals("artifact-moved", artifact.getArtifactId());
+ assertEquals("2.0", artifact.getVersion());
+ assertEquals(artifact, result.getRelocatedArtifact());
+ }
+}