You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/10/14 07:53:33 UTC
(maven) branch maven-4.0.x updated: Fix #13295: treat a missing or null snapshotVersion classifier as empty when merging
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch maven-4.0.x
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/maven-4.0.x by this push:
new 14cb5fe891 Fix #13295: treat a missing or null snapshotVersion classifier as empty when merging
14cb5fe891 is described below
commit 14cb5fe8912acaab8ea28edc0bce7e3ee81580f6
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed Oct 14 09:53:26 2026 +0200
Fix #13295: treat a missing or null snapshotVersion classifier as empty when merging
Backport of #13296 to `maven-4.0.x`.
When merging snapshot metadata, `LocalSnapshotMetadata` and `MavenSnapshotMetadata` compared `snapshotVersion.getClassifier()` with a null-sensitive equality check. A missing `<classifier/>` element produces a null value, which was not treated as equivalent to an empty string — causing snapshot versions with unset classifiers to be duplicated or incorrectly dropped during metadata merges.
- Treat null and empty classifier as equivalent when comparing `snapshotVersion` entries during merge
---
.../repository/internal/LocalSnapshotMetadata.java | 2 +-
.../repository/internal/MavenSnapshotMetadata.java | 5 +-
.../maven/impl/resolver/LocalSnapshotMetadata.java | 2 +-
.../maven/impl/resolver/MavenSnapshotMetadata.java | 5 +-
.../impl/resolver/RemoteSnapshotMetadataTest.java | 123 +++++++++++++++++++++
5 files changed, 133 insertions(+), 4 deletions(-)
diff --git a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/LocalSnapshotMetadata.java b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/LocalSnapshotMetadata.java
index e880b45560..08a6c4ed0e 100644
--- a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/LocalSnapshotMetadata.java
+++ b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/LocalSnapshotMetadata.java
@@ -126,7 +126,7 @@ protected void merge(Metadata recessive) {
}
private String getKey(String classifier, String extension) {
- return classifier + ':' + extension;
+ return (classifier == null ? "" : classifier) + ':' + extension;
}
@Override
diff --git a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/MavenSnapshotMetadata.java b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/MavenSnapshotMetadata.java
index 16134223bd..b5200d088d 100644
--- a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/MavenSnapshotMetadata.java
+++ b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/MavenSnapshotMetadata.java
@@ -62,7 +62,10 @@ public static Object getKey(Artifact artifact) {
}
protected String getKey(String classifier, String extension) {
- return classifier + ':' + extension;
+ // A <snapshotVersion> without a <classifier> element reads as null, while an artifact
+ // without a classifier has "". Both mean "no classifier" and must share a key, or a
+ // merge keeps the previous build's entry next to the new one.
+ return (classifier == null ? "" : classifier) + ':' + extension;
}
@Override
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/LocalSnapshotMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/LocalSnapshotMetadata.java
index 3d239e80ee..7161c14350 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/LocalSnapshotMetadata.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/LocalSnapshotMetadata.java
@@ -122,7 +122,7 @@ protected void merge(Metadata recessive) {
}
private String getKey(String classifier, String extension) {
- return classifier + ':' + extension;
+ return (classifier == null ? "" : classifier) + ':' + extension;
}
@Override
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MavenSnapshotMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MavenSnapshotMetadata.java
index 2afd29441f..90acd4fda7 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MavenSnapshotMetadata.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/MavenSnapshotMetadata.java
@@ -59,7 +59,10 @@ public static Object getKey(Artifact artifact) {
}
protected String getKey(String classifier, String extension) {
- return classifier + ':' + extension;
+ // A <snapshotVersion> without a <classifier> element reads as null, while an artifact
+ // without a classifier has "". Both mean "no classifier" and must share a key, or a
+ // merge keeps the previous build's entry next to the new one.
+ return (classifier == null ? "" : classifier) + ':' + extension;
}
@Override
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/RemoteSnapshotMetadataTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/RemoteSnapshotMetadataTest.java
new file mode 100644
index 0000000000..fc7c6e7800
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/RemoteSnapshotMetadataTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+
+import org.apache.maven.api.metadata.Metadata;
+import org.apache.maven.api.metadata.SnapshotVersion;
+import org.eclipse.aether.artifact.DefaultArtifact;
+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.assertTrue;
+
+/**
+ * Merging new snapshot artifacts into the version-level metadata already in the repository.
+ */
+class RemoteSnapshotMetadataTest {
+
+ @TempDir
+ Path temp;
+
+ /**
+ * Existing metadata that lists the main artifact without a {@code <classifier>} element, as
+ * Maven 3 and repository managers that regenerate metadata write it.
+ */
+ private static final String WITHOUT_CLASSIFIER_ELEMENT = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <metadata modelVersion="1.1.0">
+ <groupId>test</groupId>
+ <artifactId>repro</artifactId>
+ <versioning>
+ <lastUpdated>20260901000000</lastUpdated>
+ <snapshot>
+ <timestamp>20260901.000000</timestamp>
+ <buildNumber>1</buildNumber>
+ </snapshot>
+ <snapshotVersions>
+ <snapshotVersion>
+ <extension>pom</extension>
+ <value>1-20260901.000000-1</value>
+ <updated>20260901000000</updated>
+ </snapshotVersion>
+ <snapshotVersion>
+ <classifier>sources</classifier>
+ <extension>jar</extension>
+ <value>1-20260901.000000-1</value>
+ <updated>20260901000000</updated>
+ </snapshotVersion>
+ </snapshotVersions>
+ </versioning>
+ <version>1-SNAPSHOT</version>
+ </metadata>
+ """;
+
+ @Test
+ void newBuildReplacesAnEntryReadWithoutAClassifierElement() throws Exception {
+ List<SnapshotVersion> versions = mergeOnto(WITHOUT_CLASSIFIER_ELEMENT);
+
+ List<SnapshotVersion> poms =
+ versions.stream().filter(v -> "pom".equals(v.getExtension())).toList();
+ assertEquals(1, poms.size(), "one pom entry, not the new one plus the previous build's: " + poms);
+ assertTrue(poms.get(0).getVersion().endsWith("-2"), "the pom entry is the new build: " + poms);
+ }
+
+ @Test
+ void entriesForOtherClassifiersAreKept() throws Exception {
+ List<SnapshotVersion> versions = mergeOnto(WITHOUT_CLASSIFIER_ELEMENT);
+
+ List<SnapshotVersion> sources = versions.stream()
+ .filter(v -> "sources".equals(v.getClassifier()))
+ .toList();
+ assertEquals(1, sources.size(), "the sources jar was not redeployed and stays listed: " + versions);
+ assertEquals("1-20260901.000000-1", sources.get(0).getVersion());
+ }
+
+ @Test
+ void newBuildReplacesAnEntryWithAnEmptyClassifierElement() throws Exception {
+ String emptyClassifier = WITHOUT_CLASSIFIER_ELEMENT.replace(
+ "<extension>pom</extension>", "<classifier></classifier>\n <extension>pom</extension>");
+
+ List<SnapshotVersion> poms = mergeOnto(emptyClassifier).stream()
+ .filter(v -> "pom".equals(v.getExtension()))
+ .toList();
+
+ assertEquals(1, poms.size(), "one pom entry: " + poms);
+ assertTrue(poms.get(0).getVersion().endsWith("-2"), "the pom entry is the new build: " + poms);
+ }
+
+ private List<SnapshotVersion> mergeOnto(String existing) throws Exception {
+ Path file = temp.resolve("maven-metadata.xml");
+ Files.writeString(file, existing, StandardCharsets.UTF_8);
+ Metadata recessive = MavenMetadata.read(file);
+
+ DefaultArtifact pom = new DefaultArtifact("test:repro:pom:1-SNAPSHOT");
+ RemoteSnapshotMetadata metadata = new RemoteSnapshotMetadata(pom, Instant.now(), null);
+ metadata.bind(pom);
+ metadata.merge(recessive);
+
+ return metadata.metadata.getVersioning().getSnapshotVersions();
+ }
+}