This is an automated email from the ASF dual-hosted git repository.

rmaucher pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git

commit f0eba22fedecd64906c15aa1d2b8bdf27bc55ae2
Author: opencode <[email protected]>
AuthorDate: Fri Oct 16 12:58:13 2026 +0200

    Correct the selection of multi-release JAR entries in JarFileUrlJar
    
    The entry selected for a class present with multiple versions depended
    on the order in which entries appeared in the JAR: a versioned entry
    encountered first pinned the selection, so an older version could be
    used, and an entry that only exists for a later runtime version could
    be selected although the runtime would never use it. The selection now
    builds a map of the highest applicable versioned entry for each base
    class name, mirroring the stream-based implementation, and resolves
    every first-seen base name through it, so the scanned entry matches the
    entry the runtime would use regardless of entry order. Versioned
    non-class entries are resolved to the base entry, as the stream-based
    implementations already do, and entries only applicable to a later
    runtime are skipped.
---
 .../org/apache/tomcat/util/scan/JarFileUrlJar.java |  81 +++++++-
 .../apache/tomcat/util/scan/TestJarFileUrlJar.java | 226 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |  11 +
 3 files changed, 315 insertions(+), 3 deletions(-)

diff --git a/java/org/apache/tomcat/util/scan/JarFileUrlJar.java 
b/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
index 96208b8594..dfe1d01d15 100644
--- a/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
+++ b/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
@@ -23,7 +23,9 @@ import java.net.JarURLConnection;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.Enumeration;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
@@ -44,6 +46,7 @@ public class JarFileUrlJar implements Jar {
     private final boolean multiRelease;
     private Enumeration<JarEntry> entries;
     private Set<String> entryNamesSeen;
+    private Map<String,JarEntry> multiReleaseMap;
     private JarEntry entry = null;
 
     /**
@@ -145,6 +148,14 @@ public class JarFileUrlJar implements Jar {
             // - the case where an entry appears in the versions location
             // but not in the base location is handled correctly
 
+            // The look-up of the entry to use is based on the 'base' name of
+            // each entry and the highest applicable version of each class, so
+            // that the correct entry is selected independently of the order of
+            // the entries in the JAR.
+            if (multiReleaseMap == null) {
+                multiReleaseMap = populateMultiReleaseMap();
+            }
+
             // Enumerate the entries until one is reached that represents an
             // entry that has not been seen before.
             String name;
@@ -163,11 +174,25 @@ public class JarFileUrlJar implements Jar {
                     if (name.isEmpty() || entryNamesSeen.contains(name)) {
                         continue;
                     }
-
                     entryNamesSeen.add(name);
 
-                    // JarFile.getJarEntry is version aware so use it
-                    entry = jarFile.getJarEntry(entry.getName());
+                    JarEntry versionedEntry = multiReleaseMap.get(name);
+                    if (versionedEntry != null) {
+                        // Use the highest applicable version of this class
+                        entry = versionedEntry;
+                    } else {
+                        // Use the base entry. Classes with a version that
+                        // applies to this runtime were resolved from the
+                        // multi-release map above.
+                        entry = jarFile.getJarEntry(name);
+                    }
+
+                    if (entry == null) {
+                        // There is no entry that applies to this runtime, for
+                        // example the entry only exists in the versions
+                        // location with a version later than this runtime
+                        continue;
+                    }
                 } else {
                     entry = null;
                 }
@@ -210,6 +235,56 @@ public class JarFileUrlJar implements Jar {
     public void reset() throws IOException {
         entries = null;
         entryNamesSeen = null;
+        multiReleaseMap = null;
         entry = null;
     }
+
+
+    /**
+     * Identifies, for each class that appears in the versions location of the 
JAR, the entry for the highest version
+     * that applies to the current runtime. Keyed by 'base' name, which is the 
name with the
+     * <code>META-INF/versions/&lt;version&gt;/</code> prefix removed.
+     *
+     * @return the map of 'base' name to the entry to use for that name
+     */
+    private Map<String,JarEntry> populateMultiReleaseMap() {
+        int targetVersion = Runtime.version().feature();
+
+        Map<String,Integer> mrVersions = new HashMap<>();
+        Map<String,JarEntry> result = new HashMap<>();
+
+        // Tracking the base name and the latest valid version found is
+        // sufficient to be able to create the map of entries to use
+        Enumeration<JarEntry> jarEntries = jarFile.entries();
+        while (jarEntries.hasMoreElements()) {
+            JarEntry jarEntry = jarEntries.nextElement();
+            String name = jarEntry.getName();
+            if (name.startsWith("META-INF/versions/") && 
name.endsWith(".class")) {
+                int i = name.indexOf('/', 18);
+                if (i > 0) {
+                    String baseName = name.substring(i + 1);
+                    int version;
+                    try {
+                        version = Integer.parseInt(name, 18, i, 10);
+                    } catch (NumberFormatException e) {
+                        continue;
+                    }
+
+                    // Ignore any entries targeting for a later version than
+                    // the target for this runtime
+                    if (version <= targetVersion) {
+                        Integer mappedVersion = mrVersions.get(baseName);
+                        // Ignore any entry for which a later version has
+                        // already been found
+                        if (mappedVersion == null || version > 
mappedVersion.intValue()) {
+                            mrVersions.put(baseName, Integer.valueOf(version));
+                            result.put(baseName, jarEntry);
+                        }
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
 }
diff --git a/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java 
b/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java
new file mode 100644
index 0000000000..eacef7ee04
--- /dev/null
+++ b/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java
@@ -0,0 +1,226 @@
+/*
+ * 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.tomcat.util.scan;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import org.apache.tomcat.Jar;
+
+public class TestJarFileUrlJar {
+
+    @Rule
+    public TemporaryFolder testDir = new TemporaryFolder();
+
+    private static final String VERSIONS = "META-INF/versions/";
+    private static final String BASE_CLASS = "test/Foo.class";
+
+    private File createMultiReleaseJar(String... entryNames) throws 
IOException {
+        Manifest manifest = new Manifest();
+        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, 
"1.0");
+        manifest.getMainAttributes().putValue("Multi-Release", "true");
+
+        File file = new File(testDir.getRoot(), "mr.jar");
+        try (JarOutputStream jos = new JarOutputStream(new 
FileOutputStream(file), manifest)) {
+            for (String entryName : entryNames) {
+                jos.putNextEntry(new JarEntry(entryName));
+                jos.write(new byte[] { 1, 2, 3 });
+                jos.closeEntry();
+            }
+        }
+        return file;
+    }
+
+    private static String readEntry(Jar jar) throws IOException {
+        try (InputStream is = jar.getEntryInputStream()) {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            byte[] buffer = new byte[1024];
+            int read = is.read(buffer);
+            while (read > 0) {
+                baos.write(buffer, 0, read);
+                read = is.read(buffer);
+            }
+            return baos.toString("ISO-8859-1");
+        }
+    }
+
+    private static List<String> entryNames(Jar jar) throws IOException {
+        List<String> names = new ArrayList<>();
+        jar.nextEntry();
+        while (jar.getEntryName() != null) {
+            names.add(jar.getEntryName());
+            jar.nextEntry();
+        }
+        return names;
+    }
+
+    /*
+     * The entry selected for a class that is present with multiple versions 
must be the highest version that applies
+     * to the current runtime, regardless of the order in which the entries 
appear in the JAR.
+     */
+    @Test
+    public void testMultiReleaseVersionSelectionOrderIndependent() throws 
Exception {
+        // The lower version first
+        File file = createMultiReleaseJar(VERSIONS + "9/" + BASE_CLASS, 
VERSIONS + "11/" + BASE_CLASS, BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            Assert.assertTrue(entryNames(jar).contains(VERSIONS + "11/" + 
BASE_CLASS));
+        }
+
+        // The higher version first
+        file = createMultiReleaseJar(VERSIONS + "11/" + BASE_CLASS, VERSIONS + 
"9/" + BASE_CLASS, BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            Assert.assertTrue(entryNames(jar).contains(VERSIONS + "11/" + 
BASE_CLASS));
+        }
+
+        // The base entry first
+        file = createMultiReleaseJar(BASE_CLASS, VERSIONS + "9/" + BASE_CLASS, 
VERSIONS + "11/" + BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            Assert.assertTrue(entryNames(jar).contains(VERSIONS + "11/" + 
BASE_CLASS));
+        }
+
+        // Versioned entries only
+        file = createMultiReleaseJar(VERSIONS + "9/" + BASE_CLASS, VERSIONS + 
"11/" + BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            Assert.assertTrue(entryNames(jar).contains(VERSIONS + "11/" + 
BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * Entries that are only available for a later version than the current 
runtime must not be selected. The base
+     * entry is used where one exists.
+     */
+    @Test
+    public void testMultiReleaseFutureVersion() throws Exception {
+        // The name is unlikely to ever be a valid class but it is certain to 
be later than the current runtime
+        String futureClass = VERSIONS + "999999/" + BASE_CLASS;
+
+        // Future versioned entry first, base entry available
+        File file = createMultiReleaseJar(futureClass, BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(BASE_CLASS));
+            Assert.assertFalse(names.contains(futureClass));
+        }
+
+        // Future versioned entry only
+        file = createMultiReleaseJar(futureClass);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertFalse(names.contains(futureClass));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * The entry selected must be the entry the current runtime would use. The 
content of the selected entry is used
+     * here to make the selection visible.
+     */
+    @Test
+    public void testMultiReleaseSelectedEntryContent() throws Exception {
+        Manifest manifest = new Manifest();
+        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, 
"1.0");
+        manifest.getMainAttributes().putValue("Multi-Release", "true");
+
+        File file = new File(testDir.getRoot(), "content.jar");
+        try (JarOutputStream jos = new JarOutputStream(new 
FileOutputStream(file), manifest)) {
+            jos.putNextEntry(new JarEntry(VERSIONS + "9/" + BASE_CLASS));
+            jos.write("nine".getBytes("ISO-8859-1"));
+            jos.closeEntry();
+            jos.putNextEntry(new JarEntry(VERSIONS + "11/" + BASE_CLASS));
+            jos.write("eleven".getBytes("ISO-8859-1"));
+            jos.closeEntry();
+            jos.putNextEntry(new JarEntry(BASE_CLASS));
+            jos.write("base".getBytes("ISO-8859-1"));
+            jos.closeEntry();
+        }
+
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            String selected = null;
+            jar.nextEntry();
+            while (jar.getEntryName() != null) {
+                if (jar.getEntryName().endsWith("Foo.class")) {
+                    selected = readEntry(jar);
+                }
+                jar.nextEntry();
+            }
+            Assert.assertEquals("eleven", selected);
+        }
+    }
+
+
+    /*
+     * Re-enumeration after reset() must produce the same selection. Entries 
under a version directory that cannot be
+     * parsed must not be returned. Versioned entries for resources (non-class 
entries) are handled the same way as by
+     * the stream based implementations: the base entry is used where one 
exists and the versioned entries are
+     * ignored.
+     */
+    @Test
+    public void testMultiReleaseResetAndUnusableEntries() throws Exception {
+        String unparseable = VERSIONS + "abc/" + BASE_CLASS;
+        String versionedResource = VERSIONS + "9/test/resource.txt";
+        String resource = "test/resource.txt";
+
+        File file = createMultiReleaseJar(unparseable, versionedResource, 
VERSIONS + "9/" + BASE_CLASS,
+                VERSIONS + "11/" + BASE_CLASS, BASE_CLASS, resource);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertFalse(names.contains(unparseable));
+            Assert.assertFalse(names.contains(VERSIONS + "9/" + BASE_CLASS));
+            Assert.assertTrue(names.contains(VERSIONS + "11/" + BASE_CLASS));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+            Assert.assertFalse(names.contains(versionedResource));
+            Assert.assertTrue(names.contains(resource));
+
+            jar.reset();
+            Assert.assertEquals(names, entryNames(jar));
+        }
+    }
+
+
+    /*
+     * The version selection must work the same way for a JAR opened via a 
jar: URL.
+     */
+    @Test
+    public void testMultiReleaseVersionSelectionViaJarUrl() throws Exception {
+        File file = createMultiReleaseJar(VERSIONS + "9/" + BASE_CLASS, 
VERSIONS + "11/" + BASE_CLASS, BASE_CLASS);
+        URL url = URI.create("jar:" + file.toURI().toURL().toExternalForm() + 
"!/").toURL();
+        try (Jar jar = new JarFileUrlJar(url, true)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(VERSIONS + "11/" + BASE_CLASS));
+            Assert.assertFalse(names.contains(VERSIONS + "9/" + BASE_CLASS));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index b3b8d10afb..7533dccd51 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -360,6 +360,17 @@
         authentication type for almost every cipher suite. The computation now
         matches the one performed by the JNI based implementation. (remm)
       </fix>
+      <fix>
+        Correct the selection of multi-release JAR entries by the
+        <code>jar:file:</code> and <code>file:</code> based JAR
+        implementation used when scanning. The entry selected for a class
+        present with multiple versions depended on the order of the entries
+        in the JAR, so an older version, or a version intended for a later
+        runtime, could be scanned instead of the version the runtime would
+        use. The highest applicable version is now selected, independently
+        of the entry order, matching the behaviour of the stream based JAR
+        implementation. (remm)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Web applications">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to