diff --git a/api/maven-api-settings/src/main/mdo/settings.mdo b/api/maven-api-settings/src/main/mdo/settings.mdo index b9f37bda2277..d8b61aee9d12 100644 --- a/api/maven-api-settings/src/main/mdo/settings.mdo +++ b/api/maven-api-settings/src/main/mdo/settings.mdo @@ -545,6 +545,25 @@ * + + repositoryOrigins + 1.3.0+ + List of repository origins these credentials may be used with, each written as + {@code scheme://host} or {@code scheme://host:port}, for example {@code https://repo.example.org} + or {@code https://mirror.example.org:8443} - an origin, not a full repository URL. + Maven binds the credentials of a server to the origins of the repositories and mirrors declared + with the same id, see the {@code maven.repository.credentialScope} user property. The origins + listed here are added to those; nothing is replaced. Declaring them is needed when the repository + using these credentials is not known when the session is created, for example a repository + declared in a {@code settings.xml} profile that is not listed in {@code <activeProfiles>} + but activated through {@code <activation>} or {@code -P}. + Comparison is case insensitive and the default ports 80 (http) and 443 (https) are equivalent to + no port. This element is not supported in project settings and is dropped there. + + String + * + + diff --git a/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/validation/DefaultSettingsValidator.java b/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/validation/DefaultSettingsValidator.java index e274a2887f66..b89f8767df28 100644 --- a/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/validation/DefaultSettingsValidator.java +++ b/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/validation/DefaultSettingsValidator.java @@ -21,6 +21,8 @@ import javax.inject.Named; import javax.inject.Singleton; +import java.net.URI; +import java.net.URISyntaxException; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -109,6 +111,8 @@ public void validate(Settings settings, SettingsProblemCollector problems) { "must be unique across all server ids and aliases but found duplicate alias " + alias); } } + + validateRepositoryOrigins(problems, server, i); } } @@ -239,6 +243,81 @@ private void validateRepositories(SettingsProblemCollector problems, Liststring.length > 0 * */ + private static void validateRepositoryOrigins(SettingsProblemCollector problems, Server server, int index) { + for (int o = 0; o < server.getRepositoryOrigins().size(); o++) { + String repositoryOrigin = server.getRepositoryOrigins().get(o); + String fieldName = "servers.server[" + index + "].repositoryOrigins[" + o + "]"; + + if (!validateStringNotEmpty(problems, fieldName, repositoryOrigin, server.getId())) { + continue; + } + + // settings are validated before they are interpolated here, so a placeholder is not something + // this validator can judge + if (repositoryOrigin.contains("${")) { + continue; + } + + String invalid = invalidRepositoryOriginReason(repositoryOrigin); + if (invalid != null) { + addViolation(problems, Severity.ERROR, fieldName, server.getId(), invalid); + continue; + } + + String ignored = ignoredRepositoryOriginPartsReason(repositoryOrigin); + if (ignored != null) { + addViolation(problems, Severity.WARNING, fieldName, server.getId(), ignored); + } + } + } + + /** + * Parses a {@code } value the way the credential scoping does; see + * {@code OriginBoundAuthenticationSelector#originOf(String)} in maven-core, which is the authority + * on what an origin is. Returns {@code null} when the value is not a URI at all. + */ + private static URI parseRepositoryOrigin(String value) { + try { + return new URI(value).parseServerAuthority(); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * @return the reason why no origin can be derived from the given value, or {@code null} if one can + */ + private static String invalidRepositoryOriginReason(String value) { + URI uri = parseRepositoryOrigin(value); + if (uri == null) { + return "must be a repository origin of the form scheme://host[:port] but found '" + value + "'"; + } + if (uri.getScheme() == null) { + return "must start with a scheme, for example https://repo.example.org, but found '" + value + "'"; + } + if (uri.getHost() == null) { + return "must name a host, for example https://repo.example.org, but found '" + value + "'"; + } + if (uri.getUserInfo() != null) { + return "must not carry user information but found '" + value + "'"; + } + return null; + } + + /** + * @return the reason why parts of the given value are ignored, or {@code null} if it is a bare origin + */ + private static String ignoredRepositoryOriginPartsReason(String value) { + URI uri = parseRepositoryOrigin(value); + String path = uri.getRawPath(); + boolean extraPath = path != null && !path.isEmpty() && !"/".equals(path); + if (!extraPath && uri.getRawQuery() == null && uri.getRawFragment() == null) { + return null; + } + return "is a repository origin, not a repository URL; only '" + uri.getScheme() + "://" + uri.getAuthority() + + "' of '" + value + "' is used"; + } + private static boolean validateStringNotEmpty( SettingsProblemCollector problems, String fieldName, String string, String sourceHint) { if (!validateNotNull(problems, fieldName, string, sourceHint)) { diff --git a/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/validation/DefaultSettingsValidatorTest.java b/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/validation/DefaultSettingsValidatorTest.java index 4bec76a359bf..a16af1a4e31b 100644 --- a/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/validation/DefaultSettingsValidatorTest.java +++ b/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/validation/DefaultSettingsValidatorTest.java @@ -271,6 +271,75 @@ void testValidateServerIdAliasesWithEmptyValue() { assertContains(problems.messages.get(0), "'servers.server[0].aliases[0]' for server-1 is missing"); } + @Test + void testValidateServerRepositoryOrigins() { + SimpleProblemCollector problems = validateRepositoryOrigins( + "https://repo.example.org", "https://mirror.example.org:8443", "HTTP://Repo.Example.Org:80"); + assertEquals(0, problems.messages.size()); + } + + @Test + void testValidateServerRepositoryOriginWithoutScheme() { + SimpleProblemCollector problems = validateRepositoryOrigins("repo.example.org"); + assertEquals(1, problems.messages.size()); + assertContains( + problems.messages.get(0), + "'servers.server[0].repositoryOrigins[0]' for server-1 must start with a scheme," + + " for example https://repo.example.org, but found 'repo.example.org'"); + } + + @Test + void testValidateServerRepositoryOriginWithoutHost() { + SimpleProblemCollector problems = validateRepositoryOrigins("file:/tmp/repo"); + assertEquals(1, problems.messages.size()); + assertContains(problems.messages.get(0), "must name a host"); + } + + @Test + void testValidateServerRepositoryOriginWithUserInfo() { + SimpleProblemCollector problems = validateRepositoryOrigins("https://user:pwd@repo.example.org"); + assertEquals(1, problems.messages.size()); + assertContains(problems.messages.get(0), "must not carry user information"); + } + + @Test + void testValidateServerRepositoryOriginEmpty() { + SimpleProblemCollector problems = validateRepositoryOrigins(""); + assertEquals(1, problems.messages.size()); + assertContains(problems.messages.get(0), "'servers.server[0].repositoryOrigins[0]' for server-1 is missing"); + } + + @Test + void testValidateServerRepositoryOriginWithPath() { + SimpleProblemCollector problems = validateRepositoryOrigins("https://repo.example.org/releases/"); + assertEquals(1, problems.messages.size()); + assertContains( + problems.messages.get(0), + "is a repository origin, not a repository URL; only 'https://repo.example.org'" + + " of 'https://repo.example.org/releases/' is used"); + } + + @Test + void testValidateServerRepositoryOriginWithPlaceholderIsNotValidated() { + // this validator runs before the settings are interpolated + SimpleProblemCollector problems = validateRepositoryOrigins("${env.REPO_URL}"); + assertEquals(0, problems.messages.size()); + } + + private SimpleProblemCollector validateRepositoryOrigins(String... repositoryOrigins) { + Settings settings = new Settings(); + Server server = new Server(); + server.setId("server-1"); + for (String repositoryOrigin : repositoryOrigins) { + server.addRepositoryOrigin(repositoryOrigin); + } + settings.addServer(server); + + SimpleProblemCollector problems = new SimpleProblemCollector(); + validator.validate(settings, problems); + return problems; + } + private static class SimpleProblemCollector implements SettingsProblemCollector { List messages = new ArrayList<>(); diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java index 45ca17114bd3..a99cbc2780d8 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java @@ -105,15 +105,20 @@ public class DefaultRepositorySystemSessionFactory implements RepositorySystemSe * User property selecting how server credentials configured in settings are scoped to repositories: *
    *
  • {@code origin} (default): credentials for a server id are only used with a repository whose - * origin (protocol, host and port) matches a repository or mirror declared with the same id in - * settings or on the command line. For server ids without any such declared repository (for - * example pure deployment servers whose URL comes from the project's + * origin (protocol, host and port) matches an origin declared for the same id, either by a + * repository or mirror declared with that id in settings or on the command line, or by the + * {@code } of that server in settings. For server ids without any such declared + * origin (for example pure deployment servers whose URL comes from the project's * {@code distributionManagement}), credentials are used as before, but a warning identifying the * target origin is emitted.
  • *
  • {@code strict}: like {@code origin}, but credentials are refused for server ids that have no - * repository or mirror declared in settings or on the command line.
  • + * declared origin at all. *
  • {@code id}: legacy behavior, credentials are matched by server id only.
  • *
+ *

+ * Repositories declared inside a settings {@code } only contribute an origin when the profile + * id is listed in {@code }; profiles activated through {@code } or + * {@code -P} contribute none, which is what {@code } is for. * * @since 4.0.0 */ @@ -214,8 +219,9 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request) .buildVersionFilter(mergedProps.get(Constants.MAVEN_VERSION_FILTER), this::parseVersionConstraint) .ifPresent(sessionBuilder::setVersionFilter); - // 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 + // origins of the repositories and mirrors the operator declared for a given server id, completed + // below with the origins declared on the servers themselves, used to scope that id's credentials + // to the origin(s) it was actually configured for Map> declaredRepositoryOrigins = new HashMap<>(); DefaultMirrorSelector mirrorSelector = new DefaultMirrorSelector(); @@ -260,6 +266,13 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request) authBuilder.addPrivateKey(server.getPrivateKey(), server.getPassphrase()); authSelector.add(server.getId(), authBuilder.build()); + // origins the operator bound to this id explicitly, added to the ones collected above from the + // mirrors and the repositories of the request + for (String repositoryOrigin : server.getRepositoryOrigins()) { + OriginBoundAuthenticationSelector.addOrigin( + declaredRepositoryOrigins, server.getId(), repositoryOrigin); + } + if (server.getConfiguration() != null) { XmlNode dom = server.getDelegate().getConfiguration(); List children = dom.children().stream() diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java index 030b38742d32..a9f16621af34 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/OriginBoundAuthenticationSelector.java @@ -38,10 +38,11 @@ * 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. + * only to a repository whose origin matches one the operator declared for that id, either by declaring + * a repository or mirror with that id in settings or on the command line, or by listing the origin in + * that server's {@code } in settings. 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 */ @@ -135,8 +136,10 @@ public Authentication getAuthentication(RemoteRepository repository) { id, origin, "Not using credentials of server '" + id + "' for repository " + repository.getUrl() - + ": the repository or mirror declared for this id resides at " + origins - + ". Set " + + ": the origins declared for this id are " + origins + + ". Add " + originHint(origin) + + " to of that server in settings if these credentials belong" + + " there, or set " + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + "=" + SCOPE_ID + " to restore legacy id-only credential matching."); return null; @@ -147,8 +150,10 @@ public Authentication getAuthentication(RemoteRepository repository) { 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."); + + " line and the server declares no , and " + + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + + "=" + SCOPE_STRICT + " is in effect. Declare " + originHint(origin) + + " in of that server in settings to allow it."); return null; } warnOnce( @@ -156,12 +161,19 @@ public Authentication getAuthentication(RemoteRepository repository) { 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 " + + " command line and the server declares no . Declare " + + originHint(origin) + + " in of that server in settings to bind these credentials" + + " explicitly, or set " + DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE + "=" + SCOPE_STRICT + " to refuse such credential use."); return auth; } + private static String originHint(String origin) { + return origin != null ? "'" + origin + "'" : "its origin"; + } + private void warnOnce(String id, String origin, String message) { if (reported.add(id + "->" + origin)) { logger.warn(message); diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java index c6d30a13e562..aae3f8c5a352 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java @@ -40,12 +40,15 @@ import org.apache.maven.impl.InternalSession; import org.apache.maven.internal.impl.DefaultTypeRegistry; import org.apache.maven.rtinfo.RuntimeInformation; +import org.apache.maven.settings.Mirror; import org.apache.maven.settings.Server; import org.codehaus.plexus.configuration.PlexusConfiguration; import org.codehaus.plexus.testing.PlexusTest; import org.codehaus.plexus.util.xml.Xpp3Dom; import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.collection.VersionFilterBuilder; +import org.eclipse.aether.repository.AuthenticationSelector; +import org.eclipse.aether.repository.RemoteRepository; import org.eclipse.aether.repository.RepositoryPolicy; import org.eclipse.aether.version.VersionScheme; import org.junit.jupiter.api.Test; @@ -437,6 +440,105 @@ void transportConfigurationTest() throws InvalidRepositoryException { properties.remove("maven.resolver.transport"); } + @Test + void credentialsServedForOriginDeclaredOnlyByServer() throws InvalidRepositoryException { + MavenExecutionRequest request = requestWithServer( + serverWithRepositoryOrigins("internal", "https://repo.example.org", "https://mirror.example.org:8443")); + + AuthenticationSelector selector = authenticationSelector(request); + + assertNotNull(selector.getAuthentication(repository("internal", "https://repo.example.org/releases/"))); + assertNotNull(selector.getAuthentication(repository("internal", "https://mirror.example.org:8443/repo/"))); + assertNull(selector.getAuthentication(repository("internal", "https://evil.example.org/releases/"))); + } + + @Test + void credentialsServedForOriginDeclaredOnlyByServerInStrictScope() throws InvalidRepositoryException { + MavenExecutionRequest request = + requestWithServer(serverWithRepositoryOrigins("internal", "https://repo.example.org")); + Properties properties = new Properties(); + properties.put(DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE, "strict"); + request.setSystemProperties(properties); + + AuthenticationSelector selector = authenticationSelector(request); + + assertNotNull(selector.getAuthentication(repository("internal", "https://repo.example.org/releases/"))); + assertNull(selector.getAuthentication(repository("internal", "https://evil.example.org/releases/"))); + } + + @Test + void serverWithoutRepositoryOriginsIsRefusedInStrictScope() throws InvalidRepositoryException { + MavenExecutionRequest request = requestWithServer(serverWithRepositoryOrigins("internal")); + Properties properties = new Properties(); + properties.put(DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE, "strict"); + request.setSystemProperties(properties); + + AuthenticationSelector selector = authenticationSelector(request); + + assertNull(selector.getAuthentication(repository("internal", "https://repo.example.org/releases/"))); + } + + @Test + void serverRepositoryOriginsAddToMirrorOrigins() throws InvalidRepositoryException { + MavenExecutionRequest request = + requestWithServer(serverWithRepositoryOrigins("internal", "https://repo.example.org")); + Mirror mirror = new Mirror(); + mirror.setId("internal"); + mirror.setUrl("https://mirror.example.org/repo/"); + mirror.setMirrorOf("*"); + request.setMirrors(new ArrayList<>(List.of(mirror))); + + AuthenticationSelector selector = authenticationSelector(request); + + assertNotNull(selector.getAuthentication(repository("internal", "https://mirror.example.org/repo/"))); + assertNotNull(selector.getAuthentication(repository("internal", "https://repo.example.org/releases/"))); + assertNull(selector.getAuthentication(repository("internal", "https://evil.example.org/releases/"))); + } + + @Test + void malformedServerRepositoryOriginIsIgnored() throws InvalidRepositoryException { + MavenExecutionRequest request = requestWithServer(serverWithRepositoryOrigins("internal", "not an origin")); + Properties properties = new Properties(); + properties.put(DefaultRepositorySystemSessionFactory.MAVEN_REPOSITORY_CREDENTIAL_SCOPE, "strict"); + request.setSystemProperties(properties); + + AuthenticationSelector selector = authenticationSelector(request); + + assertNull(selector.getAuthentication(repository("internal", "https://repo.example.org/releases/"))); + } + + private static Server serverWithRepositoryOrigins(String id, String... repositoryOrigins) { + Server server = new Server(); + server.setId(id); + server.setUsername("jason"); + server.setPassword("abc123"); + server.setRepositoryOrigins(List.of(repositoryOrigins)); + return server; + } + + private MavenExecutionRequest requestWithServer(Server server) throws InvalidRepositoryException { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setLocalRepository(getLocalRepository()); + request.setServers(new ArrayList<>(List.of(server))); + return request; + } + + private AuthenticationSelector authenticationSelector(MavenExecutionRequest request) { + DefaultRepositorySystemSessionFactory systemSessionFactory = new DefaultRepositorySystemSessionFactory( + aetherRepositorySystem, + eventSpyDispatcher, + information, + defaultTypeRegistry, + versionScheme, + Collections.emptyMap(), + versionFilterBuilder); + return systemSessionFactory.newRepositorySession(request).getAuthenticationSelector(); + } + + private static RemoteRepository repository(String id, String url) { + return new RemoteRepository.Builder(id, "default", url).build(); + } + protected ArtifactRepository getLocalRepository() throws InvalidRepositoryException { File repoDir = new File(getBasedir(), "target/local-repo").getAbsoluteFile(); diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsBuilder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsBuilder.java index e061346900c5..39f8d47bfc94 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsBuilder.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsBuilder.java @@ -228,6 +228,7 @@ private Settings readSettings( .filePermissions(null) .directoryPermissions(null) .aliases(List.of()) + .repositoryOrigins(List.of()) .build()) .toList()) .build(); diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsValidator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsValidator.java index 4887dffe725f..f79e15c19817 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsValidator.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSettingsValidator.java @@ -18,6 +18,8 @@ */ package org.apache.maven.impl; +import java.net.URI; +import java.net.URISyntaxException; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -76,6 +78,10 @@ public void validate(Settings settings, boolean isProjectSettings, ProblemCollec if (!server.getAliases().isEmpty()) { addViolation(problems, BuilderProblem.Severity.WARNING, serverField + ".aliases", null, msgP); } + if (!server.getRepositoryOrigins().isEmpty()) { + addViolation( + problems, BuilderProblem.Severity.WARNING, serverField + ".repositoryOrigins", null, msgP); + } } } @@ -142,6 +148,8 @@ public void validate(Settings settings, boolean isProjectSettings, ProblemCollec "must be unique across all server ids and aliases but found duplicate alias " + alias); } } + + validateRepositoryOrigins(problems, server, i); } } @@ -289,6 +297,87 @@ private void validateRepositories( *

  • string.length == 0 * */ + private static void validateRepositoryOrigins(ProblemCollector problems, Server server, int index) { + for (int o = 0; o < server.getRepositoryOrigins().size(); o++) { + String repositoryOrigin = server.getRepositoryOrigins().get(o); + String fieldName = "servers.server[" + index + "].repositoryOrigins[" + o + "]"; + + if (!validateStringNotEmpty(problems, fieldName, repositoryOrigin, server.getId())) { + continue; + } + + // settings are interpolated before they are validated, so a placeholder left here is a defect + // and would silently never match any repository + if (repositoryOrigin.contains("${")) { + addViolation( + problems, + BuilderProblem.Severity.ERROR, + fieldName, + server.getId(), + "contains an unresolved property placeholder: '" + repositoryOrigin + "'"); + continue; + } + + String invalid = invalidRepositoryOriginReason(repositoryOrigin); + if (invalid != null) { + addViolation(problems, BuilderProblem.Severity.ERROR, fieldName, server.getId(), invalid); + continue; + } + + String ignored = ignoredRepositoryOriginPartsReason(repositoryOrigin); + if (ignored != null) { + addViolation(problems, BuilderProblem.Severity.WARNING, fieldName, server.getId(), ignored); + } + } + } + + /** + * Parses a {@code } value the way the credential scoping does; see + * {@code OriginBoundAuthenticationSelector#originOf(String)} in maven-core, which is the authority + * on what an origin is. Returns {@code null} when the value is not a URI at all. + */ + private static URI parseRepositoryOrigin(String value) { + try { + return new URI(value).parseServerAuthority(); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * @return the reason why no origin can be derived from the given value, or {@code null} if one can + */ + private static String invalidRepositoryOriginReason(String value) { + URI uri = parseRepositoryOrigin(value); + if (uri == null) { + return "must be a repository origin of the form scheme://host[:port] but found '" + value + "'"; + } + if (uri.getScheme() == null) { + return "must start with a scheme, for example https://repo.example.org, but found '" + value + "'"; + } + if (uri.getHost() == null) { + return "must name a host, for example https://repo.example.org, but found '" + value + "'"; + } + if (uri.getUserInfo() != null) { + return "must not carry user information but found '" + value + "'"; + } + return null; + } + + /** + * @return the reason why parts of the given value are ignored, or {@code null} if it is a bare origin + */ + private static String ignoredRepositoryOriginPartsReason(String value) { + URI uri = parseRepositoryOrigin(value); + String path = uri.getRawPath(); + boolean extraPath = path != null && !path.isEmpty() && !"/".equals(path); + if (!extraPath && uri.getRawQuery() == null && uri.getRawFragment() == null) { + return null; + } + return "is a repository origin, not a repository URL; only '" + uri.getScheme() + "://" + uri.getAuthority() + + "' of '" + value + "' is used"; + } + private static boolean validateStringEmpty( ProblemCollector problems, String fieldName, String string, String message) { if (string == null || string.isEmpty()) { diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsBuilderFactoryTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsBuilderFactoryTest.java index f4a9fef36553..16210f65f46d 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsBuilderFactoryTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsBuilderFactoryTest.java @@ -161,6 +161,23 @@ void testSettingsWithDuplicateServersIds() throws Exception { problems.problems().findFirst().orElseThrow().getMessage()); } + @Test + void testSettingsWithServerRepositoryOrigins() { + Settings settings = execute("settings-servers-4").getEffectiveSettings(); + + List servers = settings.getServers(); + assertEquals(2, servers.size()); + + List repositoryOrigins = List.of("https://repo.example.org", "https://mirror.example.org:8443"); + + Server server1 = getServerById(servers, "server-1"); + assertEquals(repositoryOrigins, server1.getRepositoryOrigins()); + + // an alias is the same credentials under another id, so it is bound to the same origins + Server server11 = getServerById(servers, "server-11"); + assertEquals(repositoryOrigins, server11.getRepositoryOrigins()); + } + @Test void testRelativeLocalRepositoryIsResolvedToAbsolute() { Settings settings = execute("settings-relative-local-repo").getEffectiveSettings(); diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsParserTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsParserTest.java index 0fd89f4c4935..ecfe6a21f947 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsParserTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsParserTest.java @@ -410,6 +410,34 @@ void projectSettingsRestrictionsApplyToCustomParser() throws Exception { assertTrue(result.getProblems().hasWarningProblems()); } + @Test + void projectSettingsCannotWidenServerCredentialOrigins() throws Exception { + var result = builder(Map.of()) + .build(SettingsBuilderRequest.builder() + .session(mock(Session.class)) + .userSettingsSource(source( + "user.xml", + "repository" + + "user" + + "https://good.example.org" + + "")) + .projectSettingsSource(source( + "project.xml", + "repository" + + "https://evil.example.org" + + "")) + .build()); + + // a server of the project settings is kept as a separate entry of the same id, so the origins of + // every entry matter: none of them may come from the project + List repositoryOrigins = result.getEffectiveSettings().getServers().stream() + .filter(server -> "repository".equals(server.getId())) + .flatMap(server -> server.getRepositoryOrigins().stream()) + .toList(); + assertEquals(List.of("https://good.example.org"), repositoryOrigins); + assertTrue(result.getProblems().hasWarningProblems()); + } + @Test void customSettingsDecryptionFailureDoesNotExposeCredentials() throws Exception { String encrypted = "{L6L/HbmrY+cH+sNkphn-corrupted-q3fguYepTpM04WlIXb8nB1pk=}"; diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsValidatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsValidatorTest.java index 46ae074de0eb..aaee1eecad3e 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsValidatorTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/DefaultSettingsValidatorTest.java @@ -117,4 +117,110 @@ void testValidateServerIdAliasesWithEmptyValue() { "'servers.server[0].aliases[0]' for server-1 is missing", problems.problems().findFirst().orElseThrow().getMessage()); } + + @Test + void testValidateServerRepositoryOrigins() { + Server server = Server.newBuilder() + .id("server-1") + .repositoryOrigins(List.of( + "https://repo.example.org", + "https://mirror.example.org:8443", + "HTTP://Repo.Example.Org:80", + "https://repo.example.org/")) + .build(); + + Settings settings = Settings.newBuilder().servers(List.of(server)).build(); + + ProblemCollector problems = validator.validate(settings); + assertEquals(0, problems.totalProblemsReported()); + } + + @Test + void testValidateServerRepositoryOriginWithoutScheme() { + ProblemCollector problems = validateRepositoryOrigin("repo.example.org"); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 must start with a scheme," + + " for example https://repo.example.org, but found 'repo.example.org'", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + @Test + void testValidateServerRepositoryOriginWithoutHost() { + ProblemCollector problems = validateRepositoryOrigin("file:/tmp/repo"); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 must name a host," + + " for example https://repo.example.org, but found 'file:/tmp/repo'", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + @Test + void testValidateServerRepositoryOriginWithUserInfo() { + ProblemCollector problems = validateRepositoryOrigin("https://user:pwd@repo.example.org"); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 must not carry user information" + + " but found 'https://user:pwd@repo.example.org'", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + @Test + void testValidateServerRepositoryOriginWithPlaceholder() { + ProblemCollector problems = validateRepositoryOrigin("${env.REPO_URL}"); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 contains an unresolved property" + + " placeholder: '${env.REPO_URL}'", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + @Test + void testValidateServerRepositoryOriginEmpty() { + ProblemCollector problems = validateRepositoryOrigin(""); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 is missing", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + @Test + void testValidateServerRepositoryOriginWithPathIsOnlyWarned() { + ProblemCollector problems = validateRepositoryOrigin("https://repo.example.org/releases/"); + assertEquals(1, problems.totalProblemsReported()); + BuilderProblem problem = problems.problems().findFirst().orElseThrow(); + assertEquals(BuilderProblem.Severity.WARNING, problem.getSeverity()); + assertEquals( + "'servers.server[0].repositoryOrigins[0]' for server-1 is a repository origin," + + " not a repository URL; only 'https://repo.example.org' of" + + " 'https://repo.example.org/releases/' is used", + problem.getMessage()); + } + + @Test + void testValidateServerRepositoryOriginsOnProjectSettings() { + Server server = Server.newBuilder() + .id("server-1") + .repositoryOrigins(List.of("https://repo.example.org")) + .build(); + + Settings settings = Settings.newBuilder().servers(List.of(server)).build(); + + ProblemCollector problems = validator.validate(settings, true); + assertEquals(1, problems.totalProblemsReported()); + assertEquals( + "'servers.server[0].repositoryOrigins' are not supported on project settings.", + problems.problems().findFirst().orElseThrow().getMessage()); + } + + private ProblemCollector validateRepositoryOrigin(String repositoryOrigin) { + Server server = Server.newBuilder() + .id("server-1") + .repositoryOrigins(List.of(repositoryOrigin)) + .build(); + + Settings settings = Settings.newBuilder().servers(List.of(server)).build(); + + return validator.validate(settings); + } } diff --git a/impl/maven-impl/src/test/resources/settings/settings-servers-4.xml b/impl/maven-impl/src/test/resources/settings/settings-servers-4.xml new file mode 100644 index 000000000000..4c3923b1ef93 --- /dev/null +++ b/impl/maven-impl/src/test/resources/settings/settings-servers-4.xml @@ -0,0 +1,38 @@ + + + + + + + + server-1 + username1 + password1 + + server-11 + + + https://repo.example.org + https://mirror.example.org:8443 + + + + +