DefaultSettingsParserTest.java
/*
* 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.impl;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.apache.maven.api.Constants;
import org.apache.maven.api.Session;
import org.apache.maven.api.services.BuilderProblem;
import org.apache.maven.api.services.Interpolator;
import org.apache.maven.api.services.SettingsBuilderException;
import org.apache.maven.api.services.SettingsBuilderRequest;
import org.apache.maven.api.services.SettingsBuilderResult;
import org.apache.maven.api.services.Source;
import org.apache.maven.api.services.Sources;
import org.apache.maven.api.services.xml.SettingsXmlFactory;
import org.apache.maven.api.settings.Server;
import org.apache.maven.api.settings.Settings;
import org.apache.maven.api.spi.SettingsParser;
import org.apache.maven.api.spi.SettingsParserException;
import org.apache.maven.di.Injector;
import org.apache.maven.impl.model.DefaultInterpolator;
import org.codehaus.plexus.components.secdispatcher.Dispatcher;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class DefaultSettingsParserTest {
@TempDir
Path directory;
@Test
void customSettings() throws Exception {
Path source = directory.resolve("settings.properties");
Files.writeString(source, propertiesSettings() + "\n");
var result = build(Sources.fromPath(source), Map.of("properties", new PropertiesSettingsParser()));
assertEquals(
directory.resolve("repository").toString(),
result.getEffectiveSettings().getLocalRepository());
}
@Test
void sourceWithoutBackingFile() throws Exception {
var result = build(
source("memory.properties", propertiesSettings()),
Map.of("properties", new PropertiesSettingsParser()));
assertEquals(
directory.resolve("repository").toString(),
result.getEffectiveSettings().getLocalRepository());
}
@Test
void xmlFallbackDoesNotRequireXmlExtension() throws Exception {
var result = build(
source("settings.conf", "<settings><offline>true</offline></settings>"),
Map.of("properties", new PropertiesSettingsParser()));
assertTrue(result.getEffectiveSettings().isOffline());
}
@Test
void xmlStrictFailureBecomesWarningAfterLenientParsing() throws Exception {
var result = build(source("settings.xml", "<settings>\n<unknown/>\n</settings>"), Map.of());
var problems =
result.getProblems().problems(BuilderProblem.Severity.WARNING).toList();
assertEquals(1, problems.size());
assertEquals(2, problems.get(0).getLineNumber());
assertTrue(problems.get(0).getColumnNumber() > 0);
}
@Test
void malformedXmlIsFatal() throws Exception {
var error = assertThrows(
SettingsBuilderException.class, () -> build(source("settings.xml", "<settings>\n<offline>"), Map.of()));
assertTrue(error.getMessage().contains("Non-parseable settings settings.xml"));
assertTrue(error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.allMatch(problem -> problem.getLineNumber() > 0));
}
@Test
void customStrictFailureRetriesSameParser() throws Exception {
var calls = new ArrayList<Boolean>();
SettingsParser parser = new PropertiesSettingsParser() {
@Override
public Settings parse(Source source, Map<String, ?> options) {
calls.add((Boolean) options.get(STRICT));
if (!Boolean.FALSE.equals(options.get(STRICT))) {
throw new SettingsParserException("Unknown setting", 3, 7, null);
}
return Settings.newInstance().withOffline(true);
}
};
var result = build(source("settings.properties", "unknown=value"), Map.of("properties", parser));
assertEquals(List.of(true, false), calls);
assertTrue(result.getEffectiveSettings().isOffline());
var problem = result.getProblems()
.problems(BuilderProblem.Severity.WARNING)
.findFirst()
.orElseThrow();
assertEquals("Unknown setting", problem.getMessage());
assertEquals(3, problem.getLineNumber());
assertEquals(7, problem.getColumnNumber());
}
@Test
void selectedParserFailureDoesNotFallBackToXml() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
when(parser.parse(any(), any())).thenThrow(new SettingsParserException("Invalid custom settings", 4, 2, null));
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", "<settings/>"), Map.of("properties", parser)));
assertTrue(error.getMessage().contains("Invalid custom settings"));
var problem = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.findFirst()
.orElseThrow();
assertEquals(4, problem.getLineNumber());
assertEquals(2, problem.getColumnNumber());
verify(parser, times(2)).parse(any(), any());
}
@Test
void strictWarningSurvivesFailedLenientParsing() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
var strict = new SettingsParserException("Unknown setting", 3, 7, null);
var lenient = new SettingsParserException("Invalid value", 5, 2, null);
when(parser.parse(any(), any())).thenThrow(strict).thenThrow(lenient);
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", ""), Map.of("properties", parser)));
var warnings = error.getProblemCollector()
.problems(BuilderProblem.Severity.WARNING)
.toList();
assertEquals(1, warnings.size());
var warning = warnings.get(0);
assertEquals("Unknown setting", warning.getMessage());
assertEquals("settings.properties", warning.getSource());
assertEquals(3, warning.getLineNumber());
assertEquals(7, warning.getColumnNumber());
assertSame(strict, warning.getException());
var fatals = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.toList();
assertEquals(1, fatals.size());
var fatal = fatals.get(0);
assertEquals("Non-parseable settings settings.properties: Invalid value", fatal.getMessage());
assertEquals(5, fatal.getLineNumber());
assertEquals(2, fatal.getColumnNumber());
assertSame(lenient, fatal.getException());
verify(parser).parse(any(), eq(Map.of(SettingsParser.STRICT, true)));
verify(parser).parse(any(), eq(Map.of(SettingsParser.STRICT, false)));
}
@Test
void strictWarningSurvivesUnreadableLenientInput() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
var strict = new SettingsParserException("Unknown setting", 3, 7, null);
var unreadable = new IOException("Read failed");
when(parser.parse(any(), any())).thenThrow(strict).thenThrow(unreadable);
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", ""), Map.of("properties", parser)));
var warnings = error.getProblemCollector()
.problems(BuilderProblem.Severity.WARNING)
.toList();
assertEquals(1, warnings.size());
assertSame(strict, warnings.get(0).getException());
var fatals = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.toList();
assertEquals(1, fatals.size());
assertEquals(
"Non-readable settings settings.properties: Read failed",
fatals.get(0).getMessage());
assertSame(unreadable, fatals.get(0).getException());
verify(parser, times(2)).parse(any(), any());
}
@Test
void causeOnlyParserFailureHasUsefulDiagnostic() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
when(parser.parse(any(), any()))
.thenThrow(new SettingsParserException(new IllegalArgumentException("Invalid value")));
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", ""), Map.of("properties", parser)));
var fatal = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.findFirst()
.orElseThrow();
assertEquals("Non-parseable settings settings.properties: Invalid value", fatal.getMessage());
}
@Test
void conflictingParsersAreReportedBeforeParsing() throws Exception {
SettingsParser first = mock(SettingsParser.class);
SettingsParser second = mock(SettingsParser.class);
when(first.supports(any())).thenReturn(true);
when(second.supports(any())).thenReturn(true);
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", "<settings/>"), Map.of("second", second, "first", first)));
var fatals = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.toList();
assertEquals(1, fatals.size());
var fatal = fatals.get(0);
assertEquals("Multiple settings parsers support this source: first, second", fatal.getMessage());
assertEquals("settings.properties", fatal.getSource());
assertEquals(-1, fatal.getLineNumber());
assertEquals(-1, fatal.getColumnNumber());
assertEquals(0, error.getProblemCollector().problemsReportedFor(BuilderProblem.Severity.WARNING));
verify(first, never()).parse(any(), any());
verify(second, never()).parse(any(), any());
}
@Test
void supportsFailurePreventsXmlFallback() throws Exception {
assertSupportsFailureStopsSelectionAndParsing("broken", false);
}
@Test
void unnamedSupportsFailurePreventsXmlFallback() throws Exception {
assertSupportsFailureStopsSelectionAndParsing(null, false);
}
@Test
void supportsFailurePreventsPreviouslyMatchedParser() throws Exception {
assertSupportsFailureStopsSelectionAndParsing("broken", true);
}
@Test
void unnamedSupportsFailurePreventsPreviouslyMatchedParser() throws Exception {
assertSupportsFailureStopsSelectionAndParsing(null, true);
}
private void assertSupportsFailureStopsSelectionAndParsing(String providerName, boolean matchingFirst)
throws Exception {
SettingsParser matching = mock(SettingsParser.class);
SettingsParser broken = mock(SettingsParser.class);
SettingsParser unvisited = mock(SettingsParser.class);
when(matching.supports(any())).thenReturn(true);
var failure = new IllegalStateException();
when(broken.supports(any())).thenThrow(failure);
var parsers = new LinkedHashMap<String, SettingsParser>();
if (matchingFirst) {
parsers.put("matching", matching);
}
parsers.put(providerName, broken);
parsers.put("unvisited", unvisited);
var xmlFactory = mock(SettingsXmlFactory.class);
var builder = new DefaultSettingsBuilder(xmlFactory, new DefaultInterpolator(), Map.of(), parsers);
var source = source("settings.xml", "<settings/>");
var error = assertThrows(
SettingsBuilderException.class,
() -> builder.build(SettingsBuilderRequest.builder()
.session(mock(Session.class))
.userSettingsSource(source)
.build()));
var fatals = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.toList();
assertEquals(1, fatals.size());
var fatal = fatals.get(0);
assertEquals(
"Settings parser '" + (providerName != null ? providerName : "<unnamed>")
+ "' failed to determine support for this source",
fatal.getMessage());
assertEquals("settings.xml", fatal.getSource());
assertEquals(-1, fatal.getLineNumber());
assertEquals(-1, fatal.getColumnNumber());
assertSame(failure, fatal.getException());
assertEquals(0, error.getProblemCollector().problemsReportedFor(BuilderProblem.Severity.WARNING));
verify(broken).supports(source);
if (matchingFirst) {
verify(matching, never()).parse(any(), any());
}
verify(broken, never()).parse(any(), any());
verifyNoInteractions(unvisited, xmlFactory);
verify(source, never()).openStream();
}
@Test
void unreadableXmlSourceIsNotRetried() throws Exception {
Source source = mock(Source.class);
when(source.getLocation()).thenReturn("unreadable.xml");
when(source.openStream()).thenThrow(new IOException("Read failed"));
var error = assertThrows(SettingsBuilderException.class, () -> build(source, Map.of()));
assertTrue(error.getMessage().contains("Non-readable settings unreadable.xml"));
verify(source).openStream();
}
@Test
void customIoFailureIsNotRetried() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
when(parser.parse(any(), any())).thenThrow(new IOException("Read failed"));
var error = assertThrows(
SettingsBuilderException.class,
() -> build(source("settings.properties", ""), Map.of("properties", parser)));
assertTrue(error.getMessage().contains("Non-readable settings settings.properties"));
verify(parser).parse(any(), any());
}
@Test
void xmlStreamsAreClosedAfterBothAttempts() throws Exception {
Source source = mock(Source.class);
when(source.getLocation()).thenReturn("settings.xml");
var streams = new ArrayList<InputStream>();
when(source.openStream()).thenAnswer(invocation -> {
InputStream stream = mock(InputStream.class);
when(stream.read(any(byte[].class), anyInt(), anyInt())).thenThrow(new IOException("Read failed"));
streams.add(stream);
return stream;
});
assertThrows(SettingsBuilderException.class, () -> build(source, Map.of()));
assertFalse(streams.isEmpty());
for (InputStream stream : streams) {
verify(stream).close();
}
}
@Test
void formatsShareInterpolationAndMergePrecedence() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any()))
.thenAnswer(call -> ((Source) call.getArgument(0)).getLocation().endsWith(".custom"));
when(parser.parse(any(), any()))
.thenReturn(Settings.newBuilder()
.servers(List.of(Server.newBuilder()
.id("repository")
.username("${account}")
.build()))
.build());
var result = builder(Map.of("custom", parser))
.build(SettingsBuilderRequest.builder()
.session(mock(Session.class))
.installationSettingsSource(source(
"installation.xml",
"<settings><servers><server><id>repository</id>"
+ "<username>installation</username></server></servers></settings>"))
.projectSettingsSource(source(
"project.xml",
"<settings><servers><server><id>repository</id>"
+ "<username>project</username></server></servers></settings>"))
.userSettingsSource(source("user.custom", ""))
.interpolationSource(key -> "account".equals(key) ? "user<&>" : null)
.build());
assertEquals(
"user<&>", result.getEffectiveSettings().getServers().get(0).getUsername());
}
@Test
void projectSettingsRestrictionsApplyToCustomParser() throws Exception {
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
when(parser.parse(any(), any())).thenReturn(Settings.newInstance().withOffline(true));
var result = builder(Map.of("custom", parser))
.build(SettingsBuilderRequest.builder()
.session(mock(Session.class))
.projectSettingsSource(source("project.custom", ""))
.build());
assertFalse(result.getEffectiveSettings().isOffline());
assertTrue(result.getProblems().hasWarningProblems());
}
@Test
void customSettingsDecryptionFailureDoesNotExposeCredentials() throws Exception {
String encrypted = "{L6L/HbmrY+cH+sNkphn-corrupted-q3fguYepTpM04WlIXb8nB1pk=}";
SettingsParser parser = mock(SettingsParser.class);
when(parser.supports(any())).thenReturn(true);
when(parser.parse(any(), any()))
.thenReturn(Settings.newBuilder()
.servers(List.of(Server.newBuilder()
.id("private-repository")
.password(encrypted)
.build()))
.build());
Session session = mock(Session.class);
when(session.getEffectiveProperties())
.thenReturn(Map.of(
Constants.MAVEN_SETTINGS_SECURITY,
Path.of("src/test/resources/settings/settings-security-decrypt.xml")
.toAbsolutePath()
.toString()));
var builder = new DefaultSettingsBuilder(
new DefaultSettingsXmlFactory(),
new DefaultInterpolator(),
Map.of("test", mock(Dispatcher.class)),
Map.of("custom", parser));
var request = SettingsBuilderRequest.builder()
.session(session)
.userSettingsSource(source("settings.custom", ""))
.build();
var error = assertThrows(SettingsBuilderException.class, () -> builder.build(request));
assertTrue(error.getMessage().contains("Could not decrypt password"));
assertTrue(error.getMessage().contains("for server private-repository"));
assertFalse(error.getMessage().contains(encrypted));
}
@Test
void nativeContainerWithoutCustomParsersUsesXml() throws Exception {
Injector injector = Injector.create()
.bindInstance(SettingsXmlFactory.class, new DefaultSettingsXmlFactory())
.bindInstance(Interpolator.class, new DefaultInterpolator())
.bindInstance(Dispatcher.class, mock(Dispatcher.class));
injector.bindImplicit(DefaultSettingsBuilder.class);
Session session = mock(Session.class);
when(session.getEffectiveProperties()).thenReturn(Map.of("user.home", directory.toString()));
var result = injector.getInstance(DefaultSettingsBuilder.class)
.build(SettingsBuilderRequest.builder()
.session(session)
.userSettingsSource(source("settings.xml", "<settings><offline>true</offline></settings>"))
.build());
assertTrue(result.getEffectiveSettings().isOffline());
}
@Test
void unnamedParserWorksInNativeContainer() throws Exception {
Injector injector = Injector.create()
.bindInstance(SettingsXmlFactory.class, new DefaultSettingsXmlFactory())
.bindInstance(Interpolator.class, new DefaultInterpolator())
.bindInstance(PropertiesSettingsParser.class, new PropertiesSettingsParser());
injector.bindImplicit(Dispatcher.class);
injector.bindImplicit(DefaultSettingsBuilder.class);
var result = injector.getInstance(DefaultSettingsBuilder.class)
.build(SettingsBuilderRequest.builder()
.session(mock(Session.class))
.userSettingsSource(source("memory.properties", propertiesSettings()))
.build());
assertEquals(
directory.resolve("repository").toString(),
result.getEffectiveSettings().getLocalRepository());
}
@Test
void unnamedParserConflictIsReported() throws Exception {
var parsers = new HashMap<String, SettingsParser>();
parsers.put(null, new PropertiesSettingsParser());
parsers.put("properties", new PropertiesSettingsParser());
var error =
assertThrows(SettingsBuilderException.class, () -> build(source("settings.properties", ""), parsers));
var fatal = error.getProblemCollector()
.problems(BuilderProblem.Severity.FATAL)
.findFirst()
.orElseThrow();
assertEquals("Multiple settings parsers support this source: <unnamed>, properties", fatal.getMessage());
}
private String propertiesSettings() throws IOException {
Properties properties = new Properties();
properties.setProperty(
"localRepository", directory.resolve("repository").toString());
ByteArrayOutputStream output = new ByteArrayOutputStream();
properties.store(output, null);
return output.toString(StandardCharsets.ISO_8859_1);
}
private DefaultSettingsBuilder builder(Map<String, SettingsParser> parsers) {
return new DefaultSettingsBuilder(
new DefaultSettingsXmlFactory(), new DefaultInterpolator(), Map.of(), parsers);
}
private SettingsBuilderResult build(Source source, Map<String, SettingsParser> parsers) {
return builder(parsers)
.build(SettingsBuilderRequest.builder()
.session(mock(Session.class))
.userSettingsSource(source)
.build());
}
private Source source(String location, String content) throws IOException {
Source source = mock(Source.class);
when(source.getLocation()).thenReturn(location);
when(source.openStream())
.thenAnswer(invocation -> new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)));
return source;
}
private static class PropertiesSettingsParser implements SettingsParser {
@Override
public boolean supports(Source source) {
return source.getLocation().endsWith(".properties");
}
@Override
public Settings parse(Source source, Map<String, ?> options) throws IOException {
Properties properties = new Properties();
try (InputStream stream = source.openStream()) {
properties.load(stream);
}
return Settings.newInstance().withLocalRepository(properties.getProperty("localRepository"));
}
}
}