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();
+    }
+}