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: + * + * + * @since 3.10.0 + */ + public static final String MAVEN_REPOSITORY_CREDENTIAL_SCOPE = "maven.repository.credentialScope"; + private static final String MAVEN_RESOLVER_TRANSPORT_KEY = "maven.resolver.transport"; private static final String MAVEN_RESOLVER_TRANSPORT_DEFAULT = "default"; @@ -277,6 +298,10 @@ public RepositorySystemSession.SessionBuilder newRepositorySessionBuilder(MavenE mainSessionBuilder.setDependencyManager(new TransitiveDependencyManager()); } + // origins of the repositories and mirrors the operator declared for a given server id, used below + // to scope that id's credentials to the origin(s) it was actually configured for + Map> 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()); + } +}