diff --git a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManager.java b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManager.java index 0d36d42f1f8c..65d64cf279dd 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManager.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManager.java @@ -228,7 +228,7 @@ public String getRemoteFilename() { @Override public String getLocalFilename(ArtifactRepository repository) { - return insertRepositoryKey(getRemoteFilename(), repository.getKey()); + return insertRepositoryKey(getRemoteFilename(), validateRepositoryKey(repository.getKey())); } private String insertRepositoryKey(String filename, String repositoryKey) { @@ -242,6 +242,32 @@ private String insertRepositoryKey(String filename, String repositoryKey) { return result; } + /** + * The repository key (its id) is used verbatim as part of a local file name, so it must lie within + * the usual coordinate character set. + */ + private static String validateRepositoryKey(String key) { + if (key == null || key.isEmpty()) { + return key; + } + if (isInvalidPathToken(key)) { + throw new IllegalArgumentException("Invalid repository key '" + key + "'"); + } + return key; + } + + private static boolean isInvalidPathToken(String value) { + if ("..".equals(value) || value.indexOf('/') >= 0 || value.indexOf('\\') >= 0 || value.indexOf(':') >= 0) { + return true; + } + for (int i = 0; i < value.length(); i++) { + if (Character.isISOControl(value.charAt(i))) { + return true; + } + } + return false; + } + @Override public void merge(org.apache.maven.repository.legacy.metadata.ArtifactMetadata metadata) { // not used diff --git a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadata.java b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadata.java index c9c7d83c563e..307051b4d141 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadata.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadata.java @@ -54,7 +54,33 @@ public String getRemoteFilename() { @Override public String getLocalFilename(ArtifactRepository repository) { - return "maven-metadata-" + repository.getKey() + ".xml"; + return "maven-metadata-" + validateRepositoryKey(repository.getKey()) + ".xml"; + } + + /** + * The repository key (its id) is used verbatim as part of a local file name, so it must lie within + * the usual coordinate character set. + */ + private static String validateRepositoryKey(String key) { + if (key == null || key.isEmpty()) { + return key; + } + if (isInvalidPathToken(key)) { + throw new IllegalArgumentException("Invalid repository key '" + key + "'"); + } + return key; + } + + private static boolean isInvalidPathToken(String value) { + if ("..".equals(value) || value.indexOf('/') >= 0 || value.indexOf('\\') >= 0 || value.indexOf(':') >= 0) { + return true; + } + for (int i = 0; i < value.length(); i++) { + if (Character.isISOControl(value.charAt(i))) { + return true; + } + } + return false; } @Override diff --git a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManager.java b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManager.java index a7d052455ad7..c45134ec0d67 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManager.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManager.java @@ -41,6 +41,7 @@ import org.apache.maven.artifact.repository.RepositoryRequest; import org.apache.maven.metadata.v4.MetadataStaxReader; import org.apache.maven.metadata.v4.MetadataStaxWriter; +import org.apache.maven.repository.legacy.ChecksumFailedException; import org.apache.maven.repository.legacy.UpdateCheckManager; import org.apache.maven.repository.legacy.WagonManager; import org.apache.maven.wagon.ResourceDoesNotExistException; @@ -118,6 +119,17 @@ public void resolve(RepositoryMetadata metadata, RepositoryRequest request) getLogger().info(metadata.getKey() + ": checking for updates from " + repository.getId()); try { wagonManager.getArtifactMetadata(metadata, repository, file, policy.getChecksumPolicy()); + updateCheckManager.touch(metadata, repository, file); + } catch (ChecksumFailedException e) { + // ChecksumFailedException is only thrown by the wagon manager under + // CHECKSUM_POLICY_FAIL: honor the strict policy by failing metadata resolution + // instead of downgrading the integrity failure to a warning. The update + // tracking file is deliberately not touched, so the next build retries + // immediately instead of trusting stale metadata for a full update interval. + throw new RepositoryMetadataResolutionException( + metadata + " failed checksum verification against repository: " + repository.getId() + + " due to an error: " + e.getMessage(), + e); } catch (ResourceDoesNotExistException e) { getLogger().debug(metadata + " could not be found on repository: " + repository.getId()); @@ -133,12 +145,12 @@ public void resolve(RepositoryMetadata metadata, RepositoryRequest request) file.delete(); // if this fails, forget about it } } + updateCheckManager.touch(metadata, repository, file); } catch (TransferFailedException e) { getLogger() .warn(metadata + " could not be retrieved from repository: " + repository.getId() + " due to an error: " + e.getMessage()); getLogger().debug("Exception", e); - } finally { updateCheckManager.touch(metadata, repository, file); } } @@ -273,7 +285,11 @@ private boolean loadMetadata( protected Metadata readMetadata(File mappingFile) throws RepositoryMetadataReadException { try (InputStream in = Files.newInputStream(mappingFile.toPath())) { - return new Metadata(new MetadataStaxReader().read(in, false)); + Metadata result = new Metadata(new MetadataStaxReader().read(in, false)); + + validateVersioning(result); + + return result; } catch (FileNotFoundException e) { throw new RepositoryMetadataReadException("Cannot read metadata from '" + mappingFile + "'", e); } catch (IOException | XMLStreamException e) { @@ -282,6 +298,51 @@ protected Metadata readMetadata(File mappingFile) throws RepositoryMetadataReadE } } + /** + * Version tokens adopted from repository metadata must be valid coordinate components; metadata carrying + * anything else is treated as invalid. + */ + private static void validateVersioning(Metadata metadata) throws RepositoryMetadataReadException { + if (metadata == null) { + return; + } + Versioning versioning = metadata.getVersioning(); + if (versioning == null) { + return; + } + validateVersionToken(versioning.getLatest()); + validateVersionToken(versioning.getRelease()); + for (String version : versioning.getVersions()) { + validateVersionToken(version); + } + for (SnapshotVersion snapshotVersion : versioning.getSnapshotVersions()) { + validateVersionToken(snapshotVersion.getVersion()); + } + Snapshot snapshot = versioning.getSnapshot(); + if (snapshot != null) { + validateVersionToken(snapshot.getTimestamp()); + } + } + + private static void validateVersionToken(String value) throws RepositoryMetadataReadException { + if (value == null || value.isEmpty()) { + return; + } + boolean valid = !"..".equals(value); + if (valid) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '/' || c == '\\' || c == ':' || Character.isISOControl(c)) { + valid = false; + break; + } + } + } + if (!valid) { + throw new RepositoryMetadataReadException("Metadata contains an invalid version token: '" + value + "'"); + } + } + /** * Ensures the last updated timestamp of the specified metadata does not refer to the future and fixes the local * metadata if necessary to allow proper merging/updating of metadata during deployment. @@ -355,7 +416,7 @@ private File getArtifactMetadataFromDeploymentRepository( try { wagonManager.getArtifactMetadataFromDeploymentRepository( - metadata, remoteRepository, file, ArtifactRepositoryPolicy.CHECKSUM_POLICY_WARN); + metadata, remoteRepository, file, getChecksumPolicy(metadata, remoteRepository)); } catch (ResourceDoesNotExistException e) { getLogger() .info(metadata + " could not be found on repository: " + remoteRepository.getId() @@ -381,6 +442,22 @@ private File getArtifactMetadataFromDeploymentRepository( return file; } + /** + * Determines the effective checksum policy for a transfer from the given repository. The + * operator-configured policy (e.g. {@code fail} via {@code -C}/{@code --strict-checksums} or a + * per-repository {@code checksumPolicy}) must govern every remote transfer, so it must not be + * hardcoded at the call sites. + */ + private String getChecksumPolicy(ArtifactMetadata metadata, ArtifactRepository repository) { + if (metadata instanceof RepositoryMetadata repositoryMetadata) { + ArtifactRepositoryPolicy policy = repositoryMetadata.getPolicy(repository); + if (policy != null && policy.getChecksumPolicy() != null) { + return policy.getChecksumPolicy(); + } + } + return ArtifactRepositoryPolicy.CHECKSUM_POLICY_WARN; + } + @Override public void deploy( ArtifactMetadata metadata, ArtifactRepository localRepository, ArtifactRepository deploymentRepository) diff --git a/compat/maven-compat/src/main/java/org/apache/maven/repository/legacy/LegacyRepositorySystem.java b/compat/maven-compat/src/main/java/org/apache/maven/repository/legacy/LegacyRepositorySystem.java index 281f4069e678..e703e23da943 100644 --- a/compat/maven-compat/src/main/java/org/apache/maven/repository/legacy/LegacyRepositorySystem.java +++ b/compat/maven-compat/src/main/java/org/apache/maven/repository/legacy/LegacyRepositorySystem.java @@ -660,7 +660,7 @@ public void retrieve( destination, remotePath, TransferListenerAdapter.newAdapter(transferListener), - ArtifactRepositoryPolicy.CHECKSUM_POLICY_WARN, + getChecksumPolicy(repository), true); } catch (org.apache.maven.wagon.TransferFailedException e) { throw new ArtifactTransferFailedException(getMessage(e, "Error transferring artifact."), e); @@ -669,6 +669,39 @@ public void retrieve( } } + /** + * Determines the effective checksum policy for a generic retrieval from the given repository. + * The operator-configured policy (e.g. {@code fail} via {@code -C}/{@code --strict-checksums}) + * must govern every remote transfer instead of a hardcoded lenient default. A generic remote + * path cannot be classified as release or snapshot, so the stricter of the two configured + * policies applies. + */ + private static String getChecksumPolicy(ArtifactRepository repository) { + String releases = + (repository.getReleases() != null) ? repository.getReleases().getChecksumPolicy() : null; + String snapshots = + (repository.getSnapshots() != null) ? repository.getSnapshots().getChecksumPolicy() : null; + String policy; + if (releases == null) { + policy = snapshots; + } else if (snapshots == null || checksumRank(releases) >= checksumRank(snapshots)) { + policy = releases; + } else { + policy = snapshots; + } + return (policy != null) ? policy : ArtifactRepositoryPolicy.CHECKSUM_POLICY_WARN; + } + + private static int checksumRank(String policy) { + if (ArtifactRepositoryPolicy.CHECKSUM_POLICY_FAIL.equals(policy)) { + return 2; + } else if (ArtifactRepositoryPolicy.CHECKSUM_POLICY_IGNORE.equals(policy)) { + return 0; + } else { + return 1; + } + } + @Override public void publish( ArtifactRepository repository, File source, String remotePath, ArtifactTransferListener transferListener) diff --git a/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManagerTest.java b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManagerTest.java new file mode 100644 index 000000000000..2c2a8fabea06 --- /dev/null +++ b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/LegacyLocalRepositoryManagerTest.java @@ -0,0 +1,58 @@ +/* + * 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.artifact.repository; + +import org.apache.maven.artifact.repository.layout.DefaultRepositoryLayout; +import org.eclipse.aether.metadata.DefaultMetadata; +import org.eclipse.aether.metadata.Metadata; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LegacyLocalRepositoryManagerTest { + + private static LegacyLocalRepositoryManager.ArtifactMetadataAdapter newAdapter() { + Metadata metadata = + new DefaultMetadata("g", "a", "1.0", "maven-metadata.xml", Metadata.Nature.RELEASE_OR_SNAPSHOT); + return new LegacyLocalRepositoryManager.ArtifactMetadataAdapter(metadata); + } + + private static ArtifactRepository repositoryWithId(String id) { + return new DefaultArtifactRepository(id, "http://example.invalid/repo", new DefaultRepositoryLayout()); + } + + @Test + void getLocalFilenameKeepsWellFormedRepositoryKeyUnchanged() { + String filename = newAdapter().getLocalFilename(repositoryWithId("central")); + + assertEquals("maven-metadata-central.xml", filename); + } + + @Test + void getLocalFilenameRejectsRepositoryKeyContainingPathSeparator() { + assertThrows( + IllegalArgumentException.class, () -> newAdapter().getLocalFilename(repositoryWithId("repo/evil"))); + } + + @Test + void getLocalFilenameRejectsRepositoryKeyThatIsAParentDirectoryReference() { + assertThrows(IllegalArgumentException.class, () -> newAdapter().getLocalFilename(repositoryWithId(".."))); + } +} diff --git a/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadataTest.java b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadataTest.java new file mode 100644 index 000000000000..c24186976349 --- /dev/null +++ b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/AbstractRepositoryMetadataTest.java @@ -0,0 +1,70 @@ +/* + * 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.artifact.repository.metadata; + +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.repository.DefaultArtifactRepository; +import org.apache.maven.artifact.repository.layout.DefaultRepositoryLayout; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Ensures that a repository key which may originate from a downloaded POM's {@code } section + * cannot select a local metadata file name outside the intended one. + */ +class AbstractRepositoryMetadataTest { + + private static ArtifactRepository repository(String id) { + return new DefaultArtifactRepository(id, "http://repo.example/r", new DefaultRepositoryLayout()); + } + + @Test + void repositoryKeyWithColonIsRejected() { + RepositoryMetadata metadata = new GroupRepositoryMetadata("org.test"); + ArtifactRepository repo = repository("central:1.0"); + + assertThrows(IllegalArgumentException.class, () -> metadata.getLocalFilename(repo)); + } + + @Test + void repositoryKeyWithDotDotSegmentIsRejected() { + RepositoryMetadata metadata = new GroupRepositoryMetadata("org.test"); + ArtifactRepository repo = repository("x/../../../../../../settings"); + + assertThrows(IllegalArgumentException.class, () -> metadata.getLocalFilename(repo)); + } + + @Test + void repositoryKeyWithBackslashIsRejected() { + RepositoryMetadata metadata = new GroupRepositoryMetadata("org.test"); + ArtifactRepository repo = repository("x\\..\\..\\settings"); + + assertThrows(IllegalArgumentException.class, () -> metadata.getLocalFilename(repo)); + } + + @Test + void wellFormedRepositoryKeyProducesExpectedFilename() { + RepositoryMetadata metadata = new GroupRepositoryMetadata("org.test"); + ArtifactRepository repo = repository("central"); + + assertEquals("maven-metadata-central.xml", metadata.getLocalFilename(repo)); + } +} diff --git a/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerTest.java b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerTest.java new file mode 100644 index 000000000000..1dcae79b6330 --- /dev/null +++ b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerTest.java @@ -0,0 +1,81 @@ +/* + * 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.artifact.repository.metadata; + +import javax.inject.Inject; +import javax.inject.Named; + +import java.io.File; +import java.util.Collections; + +import org.apache.maven.artifact.AbstractArtifactComponentTestCase; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.repository.ArtifactRepositoryPolicy; +import org.apache.maven.artifact.repository.layout.ArtifactRepositoryLayout; +import org.codehaus.plexus.util.FileUtils; +import org.junit.jupiter.api.Test; + +import static org.codehaus.plexus.testing.PlexusExtension.getBasedir; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests {@link DefaultRepositoryMetadataManager}. + */ +@Deprecated +class DefaultRepositoryMetadataManagerTest extends AbstractArtifactComponentTestCase { + + @Inject + private RepositoryMetadataManager repositoryMetadataManager; + + @Inject + @Named("default") + private ArtifactRepositoryLayout layout; + + @Override + protected String component() { + return "repositoryMetadataManager"; + } + + @Test + void testResolveHonorsConfiguredFailChecksumPolicy() throws Exception { + RepositoryMetadata metadata = new GroupRepositoryMetadata("checksum-policy-test-group"); + + ArtifactRepositoryPolicy failPolicy = new ArtifactRepositoryPolicy( + true, ArtifactRepositoryPolicy.UPDATE_POLICY_ALWAYS, ArtifactRepositoryPolicy.CHECKSUM_POLICY_FAIL); + + File remoteBase = new File(getBasedir(), "target/test-repositories/" + component() + "/remote-repository"); + FileUtils.deleteDirectory(remoteBase); + + ArtifactRepository remoteRepo = artifactRepositoryFactory.createArtifactRepository( + "test", "file://" + remoteBase.getPath(), layout, failPolicy, failPolicy); + + String remotePath = remoteRepo.pathOfRemoteRepositoryMetadata(metadata); + File remoteFile = new File(remoteBase, remotePath); + remoteFile.getParentFile().mkdirs(); + FileUtils.fileWrite(remoteFile.getAbsolutePath(), ""); + FileUtils.fileWrite(remoteFile.getAbsolutePath() + ".sha1", "0000000000000000000000000000000000000000"); + + ArtifactRepository localRepo = localRepository(); + FileUtils.deleteDirectory(new File(localRepo.getBasedir())); + + assertThrows( + RepositoryMetadataResolutionException.class, + () -> repositoryMetadataManager.resolve(metadata, Collections.singletonList(remoteRepo), localRepo)); + } +} diff --git a/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerValidationTest.java b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerValidationTest.java new file mode 100644 index 000000000000..2167606b810b --- /dev/null +++ b/compat/maven-compat/src/test/java/org/apache/maven/artifact/repository/metadata/DefaultRepositoryMetadataManagerValidationTest.java @@ -0,0 +1,63 @@ +/* + * 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.artifact.repository.metadata; + +import java.io.File; +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that {@link DefaultRepositoryMetadataManager} rejects repository metadata carrying version tokens that + * are not valid coordinate components, on the legacy read path used when metadata is loaded for merging. + */ +class DefaultRepositoryMetadataManagerValidationTest { + + private final DefaultRepositoryMetadataManager manager = new DefaultRepositoryMetadataManager(); + + @Test + void testMetadataWithInvalidVersionTokenIsRejected() { + File metadataFile = testFile("metadata-invalid-token/maven-metadata.xml"); + + RepositoryMetadataReadException exception = + assertThrows(RepositoryMetadataReadException.class, () -> manager.readMetadata(metadataFile)); + + assertTrue(exception.getMessage().contains("invalid version token"), exception.getMessage()); + } + + @Test + void testMetadataWithInvalidSnapshotTimestampIsRejected() { + File metadataFile = testFile("metadata-invalid-timestamp/maven-metadata.xml"); + + RepositoryMetadataReadException exception = + assertThrows(RepositoryMetadataReadException.class, () -> manager.readMetadata(metadataFile)); + + assertTrue(exception.getMessage().contains("invalid version token"), exception.getMessage()); + } + + private static File testFile(String resource) { + URL url = Thread.currentThread().getContextClassLoader().getResource(resource); + assertNotNull(url, "test resource not found: " + resource); + return new File(url.getFile()); + } +} diff --git a/compat/maven-compat/src/test/java/org/apache/maven/repository/legacy/LegacyRepositorySystemTest.java b/compat/maven-compat/src/test/java/org/apache/maven/repository/legacy/LegacyRepositorySystemTest.java index 82554ea83ac7..879722f82847 100644 --- a/compat/maven-compat/src/test/java/org/apache/maven/repository/legacy/LegacyRepositorySystemTest.java +++ b/compat/maven-compat/src/test/java/org/apache/maven/repository/legacy/LegacyRepositorySystemTest.java @@ -21,16 +21,21 @@ import javax.inject.Inject; import java.io.File; +import java.nio.file.Files; import java.util.Arrays; import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.repository.ArtifactRepositoryPolicy; import org.apache.maven.artifact.repository.Authentication; +import org.apache.maven.repository.ArtifactTransferFailedException; import org.apache.maven.settings.Server; import org.codehaus.plexus.testing.PlexusTest; 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Tests {@link LegacyRepositorySystem}. @@ -64,4 +69,24 @@ void testAuthenticationHandling() { assertEquals("jason", authentication.getUsername()); assertEquals("abc123", authentication.getPassword()); } + + @Test + void testRetrieveHonorsConfiguredFailChecksumPolicy(@TempDir File tempDir) throws Exception { + File remoteBase = new File(tempDir, "remote"); + remoteBase.mkdirs(); + File remoteFile = new File(remoteBase, "sample.txt"); + Files.writeString(remoteFile.toPath(), "content"); + Files.writeString(new File(remoteBase, "sample.txt.sha1").toPath(), "0000000000000000000000000000000000000000"); + + ArtifactRepositoryPolicy failPolicy = new ArtifactRepositoryPolicy( + true, ArtifactRepositoryPolicy.UPDATE_POLICY_ALWAYS, ArtifactRepositoryPolicy.CHECKSUM_POLICY_FAIL); + ArtifactRepository repository = repositorySystem.createArtifactRepository( + "test", "file://" + remoteBase.getAbsolutePath(), null, failPolicy, failPolicy); + + File destination = new File(tempDir, "sample.txt"); + + assertThrows( + ArtifactTransferFailedException.class, + () -> repositorySystem.retrieve(repository, destination, "sample.txt", null)); + } } diff --git a/compat/maven-compat/src/test/resources/metadata-invalid-timestamp/maven-metadata.xml b/compat/maven-compat/src/test/resources/metadata-invalid-timestamp/maven-metadata.xml new file mode 100644 index 000000000000..68cf46a41f3d --- /dev/null +++ b/compat/maven-compat/src/test/resources/metadata-invalid-timestamp/maven-metadata.xml @@ -0,0 +1,31 @@ + + + + org.apache.maven.its + dep-invalid-timestamp + 1.0-SNAPSHOT + + + 20120809.112920:1 + 1 + + 20120809112920 + + diff --git a/compat/maven-compat/src/test/resources/metadata-invalid-token/maven-metadata.xml b/compat/maven-compat/src/test/resources/metadata-invalid-token/maven-metadata.xml new file mode 100644 index 000000000000..8f4eb7200c7b --- /dev/null +++ b/compat/maven-compat/src/test/resources/metadata-invalid-token/maven-metadata.xml @@ -0,0 +1,28 @@ + + + + org.apache.maven.its + dep-invalid-token + 1.0-SNAPSHOT + + 1.0:2.0 + 20120809112920 + + diff --git a/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypter.java b/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypter.java index 26bd1c552453..7fc8a2f12afd 100644 --- a/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypter.java +++ b/compat/maven-settings-builder/src/main/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypter.java @@ -114,6 +114,11 @@ public SettingsDecryptionResult decrypt(SettingsDecryptionRequest request) { List proxies = new ArrayList<>(); for (Proxy proxy : request.getProxies()) { + // Clone the proxy (mirroring the server handling above) so that decrypted + // plaintext only ever lands in the SettingsDecryptionResult copies and never + // mutates the caller's live Settings object. + proxy = proxy.clone(); + String password = proxy.getPassword(); if (securityDispatcher.isAnyEncryptedString(password)) { try { diff --git a/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypterTest.java b/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypterTest.java new file mode 100644 index 000000000000..db26a01f7d0b --- /dev/null +++ b/compat/maven-settings-builder/src/test/java/org/apache/maven/settings/crypto/DefaultSettingsDecrypterTest.java @@ -0,0 +1,62 @@ +/* + * 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.settings.crypto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.maven.settings.Proxy; +import org.apache.maven.settings.Server; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; + +@Deprecated +class DefaultSettingsDecrypterTest { + + @Test + void testDecryptionDoesNotMutateCallerObjects() { + String ciphertext = "{COQLCE6DU6GtcS5P=}"; + + Server server = new Server(); + server.setId("test-server"); + server.setPassword(ciphertext); + + Proxy proxy = new Proxy(); + proxy.setId("test-proxy"); + proxy.setPassword(ciphertext); + + DefaultSettingsDecryptionRequest request = new DefaultSettingsDecryptionRequest(); + request.setServers(new ArrayList<>(List.of(server))); + request.setProxies(new ArrayList<>(List.of(proxy))); + + DefaultSettingsDecrypter decrypter = new DefaultSettingsDecrypter(new MavenSecDispatcher(Map.of())); + SettingsDecryptionResult result = decrypter.decrypt(request); + + // decryption state (including plaintext on success) must only ever land in the + // result copies, never in the caller's live settings objects + assertNotSame(server, result.getServers().get(0)); + assertNotSame(proxy, result.getProxies().get(0)); + assertEquals(ciphertext, server.getPassword()); + assertEquals(ciphertext, proxy.getPassword()); + assertEquals("test-proxy", result.getProxies().get(0).getId()); + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java index 9db0ae7428e2..5620c4c4268e 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionRangeResolver.java @@ -235,7 +235,7 @@ private Versioning readVersions( Versioning parsed = new MetadataStaxReader().read(in, false).getVersioning(); - validateVersioning(parsed); + MetadataInputValidator.validateVersioning(parsed); versioning = parsed; } @@ -250,35 +250,6 @@ private Versioning readVersions( return (versioning != null) ? versioning : Versioning.newInstance(); } - /** - * Version tokens adopted from repository metadata must be valid coordinate components; metadata carrying - * anything else is treated as invalid. - */ - private static void validateVersioning(Versioning versioning) throws IOException { - if (versioning == null) { - return; - } - for (String version : versioning.getVersions()) { - validateVersionToken(version, "version"); - } - validateVersionToken(versioning.getLatest(), "latest version"); - validateVersionToken(versioning.getRelease(), "release version"); - } - - private static void validateVersionToken(String value, String description) throws IOException { - if (value == null || value.isEmpty()) { - return; - } - boolean invalid = "..".equals(value) || value.contains("/") || value.contains("\\") || value.contains(":"); - for (int i = 0; i < value.length() && !invalid; i++) { - invalid = Character.isISOControl(value.charAt(i)); - } - if (invalid) { - throw new IOException("Rejecting metadata with invalid " + description + " '" + value - + "': must not contain '..', '/', '\\', ':' or control characters"); - } - } - private Versioning filterVersionsByRepositoryType(Versioning versioning, RemoteRepository remoteRepository) { if (remoteRepository == null) { return versioning; diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionResolver.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionResolver.java index dfc1cebcff9b..25740a0ff506 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionResolver.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultVersionResolver.java @@ -246,7 +246,7 @@ private Versioning readVersions( Versioning parsed = new MetadataStaxReader().read(in, false).getVersioning(); - validateVersioning(parsed); + MetadataInputValidator.validateVersioning(parsed); versioning = parsed; @@ -281,38 +281,7 @@ private Versioning readVersions( return (versioning != null) ? versioning : Versioning.newInstance(); } - /** - * Version tokens adopted from repository metadata must be valid coordinate components; metadata carrying - * anything else is treated as invalid. - */ - private static void validateVersioning(Versioning versioning) throws IOException { - if (versioning == null) { - return; - } - validateVersionToken(versioning.getLatest(), "latest version"); - validateVersionToken(versioning.getRelease(), "release version"); - for (SnapshotVersion snapshotVersion : versioning.getSnapshotVersions()) { - validateVersionToken(snapshotVersion.getVersion(), "snapshot version"); - } - Snapshot snapshot = versioning.getSnapshot(); - if (snapshot != null) { - validateVersionToken(snapshot.getTimestamp(), "snapshot timestamp"); - } - } - private static void validateVersionToken(String value, String description) throws IOException { - if (value == null || value.isEmpty()) { - return; - } - boolean invalid = "..".equals(value) || value.contains("/") || value.contains("\\") || value.contains(":"); - for (int i = 0; i < value.length() && !invalid; i++) { - invalid = Character.isISOControl(value.charAt(i)); - } - if (invalid) { - throw new IOException("Rejecting metadata with invalid " + description + " '" + value - + "': must not contain '..', '/', '\\', ':' or control characters"); - } - } private void invalidMetadata( RepositorySystemSession session, diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MetadataInputValidator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MetadataInputValidator.java new file mode 100644 index 000000000000..ae0b1c4ea066 --- /dev/null +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MetadataInputValidator.java @@ -0,0 +1,96 @@ +/* + * 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.resolver; + +import java.io.IOException; + +import org.apache.maven.api.metadata.Snapshot; +import org.apache.maven.api.metadata.SnapshotVersion; +import org.apache.maven.api.metadata.Versioning; + +/** + * Validates metadata content parsed from remote {@code maven-metadata.xml} files before the values + * are used to compose filesystem paths or artifact coordinates. + *

+ * Repository metadata is not covered by the model validator (it is not a POM), so version tokens, + * snapshot timestamps, and relocation coordinates must be checked at the point of use. Values that + * would map onto filesystem path-traversal segments ({@code ..}), separators ({@code /}, {@code \}), + * drive-letter delimiters ({@code :}), or ISO control characters are rejected. + * + * @since 4.1.0 + */ +public final class MetadataInputValidator { + + private MetadataInputValidator() {} + + /** + * Returns {@code true} if the value is unsafe for use as a coordinate component in a filesystem + * path: it is {@code ".."}, contains a separator ({@code /}, {@code \}, {@code :}), or contains + * an ISO control character. + */ + public static boolean isInvalidCoordinateComponent(String value) { + if (value == null || value.isEmpty()) { + return false; + } + 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; + } + + /** + * Validates all version-related tokens inside a parsed {@link Versioning} element. + * + * @throws IOException if any token is invalid + */ + public static void validateVersioning(Versioning versioning) throws IOException { + if (versioning == null) { + return; + } + validateVersionToken(versioning.getLatest(), "latest version"); + validateVersionToken(versioning.getRelease(), "release version"); + for (String version : versioning.getVersions()) { + validateVersionToken(version, "version"); + } + for (SnapshotVersion snapshotVersion : versioning.getSnapshotVersions()) { + validateVersionToken(snapshotVersion.getVersion(), "snapshot version"); + } + Snapshot snapshot = versioning.getSnapshot(); + if (snapshot != null) { + validateVersionToken(snapshot.getTimestamp(), "snapshot timestamp"); + } + } + + /** + * Validates a single version token from repository metadata. + * + * @throws IOException if the token contains path-traversal sequences, separators, or control characters + */ + public static void validateVersionToken(String value, String description) throws IOException { + if (isInvalidCoordinateComponent(value)) { + throw new IOException("Rejecting metadata with invalid " + description + " '" + value + + "': must not contain '..', '/', '\\', ':' or control characters"); + } + } +} diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSource.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSource.java index fd86d0f58c2f..c912ece00e64 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSource.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSource.java @@ -25,6 +25,7 @@ import org.apache.maven.api.model.Model; import org.apache.maven.api.model.Relocation; import org.apache.maven.impl.resolver.MavenArtifactRelocationSource; +import org.apache.maven.impl.resolver.MetadataInputValidator; import org.apache.maven.impl.resolver.RelocatedArtifact; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -56,22 +57,19 @@ public Artifact relocatedTarget( if (distMgmt != null) { Relocation relocation = distMgmt.getRelocation(); if (relocation != null) { + Artifact original = artifactDescriptorResult.getRequest().getArtifact(); validateCoordinateComponent(relocation.getGroupId(), "groupId", artifactDescriptorResult); validateCoordinateComponent(relocation.getArtifactId(), "artifactId", artifactDescriptorResult); validateCoordinateComponent(relocation.getVersion(), "version", artifactDescriptorResult); Artifact result = new RelocatedArtifact( - artifactDescriptorResult.getRequest().getArtifact(), + original, relocation.getGroupId(), relocation.getArtifactId(), null, null, relocation.getVersion(), relocation.getMessage()); - LOGGER.debug( - "The artifact {} has been relocated to {}: {}", - artifactDescriptorResult.getRequest().getArtifact(), - result, - relocation.getMessage()); + LOGGER.debug("The artifact {} has been relocated to {}: {}", original, result, relocation.getMessage()); return result; } } @@ -88,7 +86,7 @@ private static void validateCoordinateComponent( if (value == null || value.isEmpty()) { return; // component is not relocated: the original artifact's value is kept } - if (isInvalidCoordinateComponent(value)) { + if (MetadataInputValidator.isInvalidCoordinateComponent(value)) { IllegalArgumentException cause = new IllegalArgumentException("Invalid relocation " + component + " '" + value + "' in artifact descriptor for " + artifactDescriptorResult.getRequest().getArtifact() @@ -97,16 +95,4 @@ private static void validateCoordinateComponent( throw new ArtifactDescriptorException(artifactDescriptorResult, cause.getMessage(), cause); } } - - 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; - } } diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/MetadataInputValidatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/MetadataInputValidatorTest.java new file mode 100644 index 000000000000..0cf168684921 --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/MetadataInputValidatorTest.java @@ -0,0 +1,155 @@ +/* + * 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.resolver; + +import java.io.IOException; + +import org.apache.maven.api.metadata.Snapshot; +import org.apache.maven.api.metadata.SnapshotVersion; +import org.apache.maven.api.metadata.Versioning; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@link MetadataInputValidator}. + */ +class MetadataInputValidatorTest { + + // --- isInvalidCoordinateComponent --- + + @Test + void validCoordinateComponents() { + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent("commons-lang3")); + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent("1.0.0")); + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent("org.apache.maven")); + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent(".hidden")); + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent("a..b")); + } + + @Test + void nullAndEmptyAreValid() { + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent(null)); + assertFalse(MetadataInputValidator.isInvalidCoordinateComponent("")); + } + + @Test + void dotDotIsInvalid() { + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("..")); + } + + @Test + void slashIsInvalid() { + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("a/b")); + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("a\\b")); + } + + @Test + void colonIsInvalid() { + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("C:")); + } + + @Test + void controlCharacterIsInvalid() { + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("abc\0def")); + assertTrue(MetadataInputValidator.isInvalidCoordinateComponent("\t")); + } + + // --- validateVersionToken --- + + @Test + void validVersionTokensPass() { + assertDoesNotThrow(() -> MetadataInputValidator.validateVersionToken("1.0.0", "version")); + assertDoesNotThrow(() -> MetadataInputValidator.validateVersionToken("20250101.123456", "timestamp")); + assertDoesNotThrow(() -> MetadataInputValidator.validateVersionToken(null, "version")); + assertDoesNotThrow(() -> MetadataInputValidator.validateVersionToken("", "version")); + } + + @Test + void dotDotVersionTokenThrows() { + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersionToken("..", "version")); + } + + @Test + void slashVersionTokenThrows() { + assertThrows( + IOException.class, () -> MetadataInputValidator.validateVersionToken("../../../etc/passwd", "version")); + } + + @Test + void controlCharVersionTokenThrows() { + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersionToken("1.0\0", "version")); + } + + // --- validateVersioning --- + + @Test + void validVersioningPasses() { + Versioning versioning = Versioning.newBuilder() + .latest("2.0.0") + .release("1.0.0") + .versions(java.util.List.of("1.0.0", "2.0.0")) + .build(); + assertDoesNotThrow(() -> MetadataInputValidator.validateVersioning(versioning)); + } + + @Test + void nullVersioningPasses() { + assertDoesNotThrow(() -> MetadataInputValidator.validateVersioning(null)); + } + + @Test + void invalidLatestVersionThrows() { + Versioning versioning = Versioning.newBuilder().latest("..").build(); + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersioning(versioning)); + } + + @Test + void invalidReleaseVersionThrows() { + Versioning versioning = Versioning.newBuilder().release("../evil").build(); + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersioning(versioning)); + } + + @Test + void invalidVersionInListThrows() { + Versioning versioning = Versioning.newBuilder() + .versions(java.util.List.of("1.0.0", "..")) + .build(); + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersioning(versioning)); + } + + @Test + void invalidSnapshotVersionThrows() { + SnapshotVersion sv = + SnapshotVersion.newBuilder().version("1.0-../../../passwd").build(); + Versioning versioning = + Versioning.newBuilder().snapshotVersions(java.util.List.of(sv)).build(); + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersioning(versioning)); + } + + @Test + void invalidSnapshotTimestampThrows() { + Snapshot snapshot = Snapshot.newBuilder().timestamp("..").buildNumber(1).build(); + Versioning versioning = Versioning.newBuilder().snapshot(snapshot).build(); + assertThrows(IOException.class, () -> MetadataInputValidator.validateVersioning(versioning)); + } +} diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java index 304f1f58dec8..28aa6386ecf6 100644 --- a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/relocation/DistributionManagementArtifactRelocationSourceTest.java @@ -30,6 +30,7 @@ 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.assertThrows; /** @@ -59,6 +60,13 @@ private static Model newModel(String groupId, String artifactId, String version) return Model.newBuilder().distributionManagement(distMgmt).build(); } + @Test + void noRelocationReturnsNull() { + Model model = Model.newBuilder().build(); + Artifact result = source.relocatedTarget(null, newResult(), model); + assertNull(result); + } + @Test void testWellFormedRelocationIsApplied() throws Exception { final Artifact relocated = source.relocatedTarget(null, newResult(), newModel("ut.moved", "artifact", "2.0"));