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

markt-asf pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/main by this push:
     new ace667fdb2 Review "Avoid exceptions when indexing malformed archive 
entry names"
ace667fdb2 is described below

commit ace667fdb26c13fe0539580ed03be953a5a720e5
Author: Mark Thomas <[email protected]>
AuthorDate: Fri Oct 30 17:44:43 2026 +0000

    Review "Avoid exceptions when indexing malformed archive entry names"
    
    Fix various inconsistencies in the multi-release JAR handling. Handling
    should now be consistent within Tomcat and aligned with the JRE.
    
    Co-authored by: Claude / Sonnet 5.5
---
 .../apache/catalina/webresources/JarContents.java  |  20 +-
 .../catalina/webresources/JarWarResourceSet.java   |  55 +++--
 .../tomcat/util/scan/AbstractInputStreamJar.java   |  77 +++----
 .../org/apache/tomcat/util/scan/JarFileUrlJar.java |  70 ++++---
 .../apache/tomcat/util/scan/MultiReleaseUtil.java  |  97 +++++++++
 .../catalina/webresources/TestJarContents.java     |  65 +++++-
 .../webresources/TestJarWarResourceSet.java        |  88 +++++++-
 .../util/scan/TestAbstractInputStreamJar.java      | 222 +++++++++++++++++++++
 .../apache/tomcat/util/scan/TestJarFileUrlJar.java | 198 +++++++++++++++++-
 .../tomcat/util/scan/TestMultiVersionUtil.java     | 134 +++++++++++++
 webapps/docs/changelog.xml                         |   7 +
 11 files changed, 898 insertions(+), 135 deletions(-)

diff --git a/java/org/apache/catalina/webresources/JarContents.java 
b/java/org/apache/catalina/webresources/JarContents.java
index abb13c93b0..f8954c3b2e 100644
--- a/java/org/apache/catalina/webresources/JarContents.java
+++ b/java/org/apache/catalina/webresources/JarContents.java
@@ -22,6 +22,9 @@ import java.util.Enumeration;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 
+import org.apache.tomcat.util.scan.MultiReleaseUtil;
+import org.apache.tomcat.util.scan.MultiReleaseUtil.MultiVersionInfo;
+
 /**
  * This class represents the contents of a jar by determining whether a given 
resource <b>might</b> be in the cache,
  * based on a bloom filter. This is not a general-purpose bloom filter because 
it contains logic to strip out characters
@@ -95,19 +98,10 @@ public final class JarContents {
         }
 
         // Versioned entries should be added to the table according to their 
real name
-        if (name.startsWith("META-INF/versions/", startPos)) {
-            int i = name.indexOf('/', 18 + startPos);
-            if (i > 0) {
-                try {
-                    int version = Integer.parseInt(name.substring(18 + 
startPos, i));
-                    if (version <= Runtime.version().feature()) {
-                        startPos = i + 1;
-                    }
-                } catch (NumberFormatException e) {
-                    // The segment is not a valid version number, so the entry
-                    // is not a versioned entry that can be resolved under its
-                    // base name and is added to the table under its full name.
-                }
+        MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
+        if (multiVersionInfo != null) {
+            if (multiVersionInfo.isValid() && multiVersionInfo.version() <= 
Runtime.version().feature()) {
+                startPos = multiVersionInfo.namePos();
             }
             if (startPos == name.length()) {
                 return;
diff --git a/java/org/apache/catalina/webresources/JarWarResourceSet.java 
b/java/org/apache/catalina/webresources/JarWarResourceSet.java
index 9896ca2752..2e7591c2e0 100644
--- a/java/org/apache/catalina/webresources/JarWarResourceSet.java
+++ b/java/org/apache/catalina/webresources/JarWarResourceSet.java
@@ -33,6 +33,8 @@ import org.apache.catalina.LifecycleException;
 import org.apache.catalina.WebResource;
 import org.apache.catalina.WebResourceRoot;
 import org.apache.tomcat.util.buf.UriUtil;
+import org.apache.tomcat.util.scan.MultiReleaseUtil;
+import org.apache.tomcat.util.scan.MultiReleaseUtil.MultiVersionInfo;
 
 /**
  * Represents a {@link org.apache.catalina.WebResourceSet} based on a JAR file 
that is nested inside a packed WAR file.
@@ -190,53 +192,42 @@ public class JarWarResourceSet extends 
AbstractArchiveResourceSet {
         int targetVersion = Runtime.version().feature();
 
         Map<String,VersionedJarEntry> versionedEntries = new HashMap<>();
-        Map<String,JarEntry> otherEntries = new HashMap<>();
         Iterator<Entry<String,JarEntry>> iter = 
archiveEntries.entrySet().iterator();
         while (iter.hasNext()) {
             Entry<String,JarEntry> entry = iter.next();
             String name = entry.getKey();
             JarEntry jarEntry = entry.getValue();
-            if (name.startsWith("META-INF/versions/")) {
+            MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
+            if (multiVersionInfo != null && multiVersionInfo.isValid()) {
                 // Remove the multi-release version
                 iter.remove();
 
-                // Get the base name and version for this versioned entry
-                int i = name.indexOf('/', 18);
-                if (i > 0) {
-                    String baseName = name.substring(i + 1);
-                    int version;
-                    try {
-                        version = Integer.parseInt(name.substring(18, i));
-                    } catch (NumberFormatException e) {
-                        // The entry is not below a valid version directory, so
-                        // it is not a versioned entry and remains available
-                        // under its full name
-                        otherEntries.put(name, jarEntry);
+                // Ignore any entries targeting for a later version than
+                // the target for this runtime
+                if (multiVersionInfo.version() <= targetVersion) {
+                    String baseName = 
name.substring(multiVersionInfo.namePos());
+                    if (baseName.isEmpty()) {
+                        // The entry for the version directory itself. There 
is no resource to expose.
                         continue;
                     }
-
-                    // Ignore any entries targeting for a later version than
-                    // the target for this runtime
-                    if (version <= targetVersion) {
-                        VersionedJarEntry versionedJarEntry = 
versionedEntries.get(baseName);
-                        if (versionedJarEntry == null) {
-                            // No versioned entry found for this name. Create
-                            // one.
-                            versionedEntries.put(baseName, new 
VersionedJarEntry(version, jarEntry));
-                        } else {
-                            // Ignore any entry for which we have already found
-                            // a later version
-                            if (version > versionedJarEntry.version()) {
-                                // Replace the entry targeted at an earlier
-                                // version
-                                versionedEntries.put(baseName, new 
VersionedJarEntry(version, jarEntry));
-                            }
+                    VersionedJarEntry versionedJarEntry = 
versionedEntries.get(baseName);
+                    if (versionedJarEntry == null) {
+                        // No versioned entry found for this name. Create
+                        // one.
+                        versionedEntries.put(baseName, new 
VersionedJarEntry(multiVersionInfo.version(), jarEntry));
+                    } else {
+                        // Ignore any entry for which we have already found
+                        // a later version
+                        if (multiVersionInfo.version() > 
versionedJarEntry.version()) {
+                            // Replace the entry targeted at an earlier
+                            // version
+                            versionedEntries.put(baseName,
+                                    new 
VersionedJarEntry(multiVersionInfo.version(), jarEntry));
                         }
                     }
                 }
             }
         }
-        archiveEntries.putAll(otherEntries);
 
         for (Entry<String,VersionedJarEntry> versionedJarEntry : 
versionedEntries.entrySet()) {
             archiveEntries.put(versionedJarEntry.getKey(), 
versionedJarEntry.getValue().jarEntry());
diff --git a/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java 
b/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java
index cfd80056a3..c92252bcc3 100644
--- a/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java
+++ b/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java
@@ -21,11 +21,11 @@ import java.io.InputStream;
 import java.net.URL;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.jar.JarEntry;
 import java.util.jar.Manifest;
 
 import org.apache.tomcat.Jar;
+import org.apache.tomcat.util.scan.MultiReleaseUtil.MultiVersionInfo;
 
 /**
  * Base implementation of Jar for implementations that use a JarInputStream to 
access the JAR file.
@@ -71,12 +71,7 @@ public abstract class AbstractInputStreamJar implements Jar {
                 // Skip base entries where there is a multi-release entry
                 // Skip multi-release entries that are not being used
                 while (entry != null && (mrMap.containsKey(entry.getName()) ||
-                        entry.getName().startsWith("META-INF/versions/") && 
!mrMap.containsValue(entry.getName()))) {
-                    entry = jarInputStream.getNextJarEntry();
-                }
-            } else {
-                // Skip multi-release entries
-                while (entry != null && 
entry.getName().startsWith("META-INF/versions/")) {
+                        isVersionedEntry(entry.getName()) && 
!mrMap.containsValue(entry.getName()))) {
                     entry = jarInputStream.getNextJarEntry();
                 }
             }
@@ -201,6 +196,16 @@ public abstract class AbstractInputStreamJar implements 
Jar {
     protected abstract NonClosingJarInputStream createJarInputStream() throws 
IOException;
 
 
+    /*
+     * Entries below META-INF/versions/ that do not have a valid version 
directory are not versioned entries. They are
+     * ordinary entries.
+     */
+    private static boolean isVersionedEntry(String name) {
+        MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
+        return multiVersionInfo != null && multiVersionInfo.isValid();
+    }
+
+
     private void gotoEntry(String name) throws IOException {
         boolean needsReset = true;
         if (multiRelease == null) {
@@ -214,9 +219,6 @@ public abstract class AbstractInputStreamJar implements Jar 
{
             if (mrName != null) {
                 name = mrName;
             }
-        } else if (name.startsWith("META-INF/versions/")) {
-            entry = null;
-            return;
         }
 
         if (entry != null && name.equals(entry.getName())) {
@@ -241,6 +243,7 @@ public abstract class AbstractInputStreamJar implements Jar 
{
         int targetVersion = Runtime.version().feature();
 
         Map<String,Integer> mrVersions = new HashMap<>();
+        Map<String,String> newMrMap = new HashMap<>();
 
         JarEntry jarEntry = jarInputStream.getNextJarEntry();
 
@@ -248,34 +251,24 @@ public abstract class AbstractInputStreamJar implements 
Jar {
         // sufficient to be able to create the renaming map required
         while (jarEntry != null) {
             String name = jarEntry.getName();
-            if (name.startsWith("META-INF/versions/") && 
name.endsWith(".class")) {
-
-                // Get the base name and version for this versioned entry
-                int i = name.indexOf('/', 18);
-                if (i > 0) {
-                    String baseName = name.substring(i + 1);
-                    int version = 0;
-                    try {
-                        version = Integer.parseInt(name.substring(18, i));
-                    } catch (NumberFormatException e) {
-                        jarEntry = jarInputStream.getNextJarEntry();
-                        continue;
-                    }
-
-                    // Ignore any entries targeting for a later version than
-                    // the target for this runtime
-                    if (version <= targetVersion) {
-                        Integer mappedVersion = mrVersions.get(baseName);
-                        if (mappedVersion == null) {
-                            // No version found for this name. Create one.
-                            mrVersions.put(baseName, Integer.valueOf(version));
-                        } else {
-                            // Ignore any entry for which we have already found
-                            // a later version
-                            if (version > mappedVersion.intValue()) {
-                                // Replace the earlier version
-                                mrVersions.put(baseName, 
Integer.valueOf(version));
-                            }
+            MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
+            if (multiVersionInfo != null && multiVersionInfo.isValid()) {
+                // Ignore any entries targeting for a later version than
+                // the target for this runtime
+                if (multiVersionInfo.version() <= targetVersion) {
+                    String baseName = 
name.substring(multiVersionInfo.namePos());
+                    Integer mappedVersion = mrVersions.get(baseName);
+                    if (mappedVersion == null) {
+                        // No version found for this name. Create one.
+                        mrVersions.put(baseName, 
Integer.valueOf(multiVersionInfo.version()));
+                        newMrMap.put(baseName, name);
+                    } else {
+                        // Ignore any entry for which we have already found
+                        // a later version
+                        if (multiVersionInfo.version() > 
mappedVersion.intValue()) {
+                            // Replace the earlier version
+                            mrVersions.put(baseName, 
Integer.valueOf(multiVersionInfo.version()));
+                            newMrMap.put(baseName, name);
                         }
                     }
                 }
@@ -283,15 +276,9 @@ public abstract class AbstractInputStreamJar implements 
Jar {
             jarEntry = jarInputStream.getNextJarEntry();
         }
 
-        mrMap = new HashMap<>();
-
-        for (Entry<String,Integer> mrVersion : mrVersions.entrySet()) {
-            mrMap.put(mrVersion.getKey(),
-                    "META-INF/versions/" + mrVersion.getValue().toString() + 
"/" + mrVersion.getKey());
-        }
-
         // Reset stream back to the beginning of the JAR
         closeStream();
         jarInputStream = createJarInputStream();
+        mrMap = newMrMap;
     }
 }
diff --git a/java/org/apache/tomcat/util/scan/JarFileUrlJar.java 
b/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
index b5a89464ae..2929a7e59a 100644
--- a/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
+++ b/java/org/apache/tomcat/util/scan/JarFileUrlJar.java
@@ -34,6 +34,7 @@ import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
 import org.apache.tomcat.Jar;
+import org.apache.tomcat.util.scan.MultiReleaseUtil.MultiVersionInfo;
 
 /**
  * Implementation of {@link Jar} that is optimised for file based JAR URLs 
that refer directly to a JAR file (e.g. URLs
@@ -98,8 +99,7 @@ public class JarFileUrlJar implements Jar {
 
     @Override
     public InputStream getInputStream(String name) throws IOException {
-        // JarFile#getEntry() is multi-release aware
-        ZipEntry entry = jarFile.getEntry(name);
+        ZipEntry entry = getEntry(name);
         if (entry == null) {
             return null;
         } else {
@@ -109,8 +109,7 @@ public class JarFileUrlJar implements Jar {
 
     @Override
     public long getLastModified(String name) throws IOException {
-        // JarFile#getEntry() is multi-release aware
-        ZipEntry entry = jarFile.getEntry(name);
+        ZipEntry entry = getEntry(name);
         if (entry == null) {
             return -1;
         } else {
@@ -120,9 +119,25 @@ public class JarFileUrlJar implements Jar {
 
     @Override
     public boolean exists(String name) throws IOException {
-        // JarFile#getEntry() is multi-release aware
-        ZipEntry entry = jarFile.getEntry(name);
-        return entry != null;
+        return getEntry(name) != null;
+    }
+
+    /*
+     * JarFile#getEntry() is only multi-release aware if the JarFile was 
opened for the current runtime version. That is
+     * not the case when the JarFile is obtained from a jar: URL so, for 
multi-release JARs, use the same look-up of the
+     * highest applicable version that is used when enumerating the entries.
+     */
+    private ZipEntry getEntry(String name) {
+        if (multiRelease) {
+            if (multiReleaseMap == null) {
+                multiReleaseMap = populateMultiReleaseMap();
+            }
+            JarEntry versionedEntry = multiReleaseMap.get(name);
+            if (versionedEntry != null) {
+                return versionedEntry;
+            }
+        }
+        return jarFile.getEntry(name);
     }
 
     @Override
@@ -175,13 +190,10 @@ public class JarFileUrlJar implements Jar {
                 if (entries.hasMoreElements()) {
                     entry = entries.nextElement();
                     name = entry.getName();
+                    MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
                     // Get 'base' name
-                    if (name.startsWith("META-INF/versions/")) {
-                        int i = name.indexOf('/', 18);
-                        if (i == -1) {
-                            continue;
-                        }
-                        name = name.substring(i + 1);
+                    if (multiVersionInfo != null && 
multiVersionInfo.isValid()) {
+                        name = name.substring(multiVersionInfo.namePos());
                     }
                     if (name.isEmpty() || entryNamesSeen.contains(name)) {
                         continue;
@@ -271,27 +283,19 @@ public class JarFileUrlJar implements Jar {
         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;
-                    }
+            MultiVersionInfo multiVersionInfo = 
MultiReleaseUtil.parseVersion(name);
+            if (multiVersionInfo != null && multiVersionInfo.isValid()) {
+                String baseName = name.substring(multiVersionInfo.namePos());
 
-                    // 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);
-                        }
+                // Ignore any entries targeting for a later version than
+                // the target for this runtime
+                if (multiVersionInfo.version() <= targetVersion) {
+                    Integer mappedVersion = mrVersions.get(baseName);
+                    // Ignore any entry for which a later version has
+                    // already been found
+                    if (mappedVersion == null || multiVersionInfo.version() > 
mappedVersion.intValue()) {
+                        mrVersions.put(baseName, 
Integer.valueOf(multiVersionInfo.version()));
+                        result.put(baseName, jarEntry);
                     }
                 }
             }
diff --git a/java/org/apache/tomcat/util/scan/MultiReleaseUtil.java 
b/java/org/apache/tomcat/util/scan/MultiReleaseUtil.java
new file mode 100644
index 0000000000..09fd494292
--- /dev/null
+++ b/java/org/apache/tomcat/util/scan/MultiReleaseUtil.java
@@ -0,0 +1,97 @@
+/*
+ * 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;
+
+/**
+ * Utility class for working with multi-release JAR files.
+ */
+public class MultiReleaseUtil {
+
+    private static final String VERSION_PATH = "META-INF/versions/";
+    private static final int VERSION_PATH_LENGTH = VERSION_PATH.length();
+    private static final int MINIMUM_VERSIONED_JAVA = 8;
+
+
+    private MultiReleaseUtil() {
+        // Hide default constructor
+    }
+
+
+    /**
+     * Parses a JAR entry name and, if it represents a multi-release JAR 
entry, returns the target Java version and the
+     * start point of the resource name in the JAR entry name. Version numbers 
with leading zeros, signs or values less
+     * than 8 are ignored to match the JRE 21 onwards behaviour.
+     *
+     * @param name Name of the JAR entry
+     *
+     * @return The {@code MultiVersionInfo} for the entry if the entry 
represents a versioned entry, otherwise
+     *             {@code null}
+     */
+    public static MultiVersionInfo parseVersion(String name) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        int start = 0;
+        if (name.charAt(0) == '/') {
+            start = 1;
+        }
+        if (name.startsWith(VERSION_PATH, start)) {
+            int end = name.indexOf('/', VERSION_PATH_LENGTH + start);
+            if (end > 0) {
+                for (int i = VERSION_PATH_LENGTH + start; i < end; i++) {
+                    char c = name.charAt(i);
+                    if (c < '0' || c > '9' || ((i == VERSION_PATH_LENGTH + 
start) && c == '0')) {
+                        return new MultiVersionInfo(-1, end + 1);
+                    }
+                }
+                try {
+                    int version = Integer.parseInt(name, VERSION_PATH_LENGTH + 
start, end, 10);
+                    if (version < MINIMUM_VERSIONED_JAVA) {
+                        version = -1;
+                    }
+                    return new MultiVersionInfo(version, end + 1);
+                } catch (NumberFormatException e) {
+                    // Must be overflow
+                    return new MultiVersionInfo(-1, end + 1);
+                }
+            }
+            return new MultiVersionInfo(-1, -1);
+        }
+
+        return null;
+    }
+
+
+    /**
+     * Provides the return value for calls to {@code 
MultiVersionUtil#parseVersion(String)}.
+     *
+     * @param version The version or {@code -1} if the version was invalid
+     * @param namePos The start position of the name. The first character 
after the '/' after the version. {@code -1} if
+     *                    there was no '/' after the version or the version 
was not present
+     */
+    public record MultiVersionInfo(int version, int namePos) {
+
+        /**
+         * Has a valid multi-release entry been found?
+         *
+         * @return {@code true} if a valid multi-release entry was found, 
otherwise {@code false}
+         */
+        public boolean isValid() {
+            return version >= 0;
+        }
+    }
+}
diff --git a/test/org/apache/catalina/webresources/TestJarContents.java 
b/test/org/apache/catalina/webresources/TestJarContents.java
index edf1c1f756..3ed469a58d 100644
--- a/test/org/apache/catalina/webresources/TestJarContents.java
+++ b/test/org/apache/catalina/webresources/TestJarContents.java
@@ -151,10 +151,69 @@ public class TestJarContents {
             // A leading slash is not significant
             Assert.assertTrue(
                     
jarContents.mightContainResource("/META-INF/versions/9x/lead.txt"));
-            // Signed and valid version segments record the base name
-            Assert.assertTrue(jarContents.mightContainResource("plus.txt"));
-            Assert.assertTrue(jarContents.mightContainResource("minus.txt"));
+            // Signed version segments are invalid
+            
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/+9/plus.txt"));
+            
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/-9/minus.txt"));
+            // Valid version segments record the base name
             Assert.assertTrue(jarContents.mightContainResource("ok.txt"));
         }
     }
+
+    @Test
+    public void testVersionedEntryForCurrentRuntime() throws Exception {
+        // An entry below the version directory for the running Java version
+        // is resolved under its base name. An entry targeting a later
+        // version is not applicable and remains under its full name.
+        int feature = Runtime.version().feature();
+        String current = "META-INF/versions/" + feature + "/current.txt";
+        String later = "META-INF/versions/" + (feature + 1) + "/later.txt";
+        File f = createJar(current, later);
+
+        try (JarFile jarFile = new JarFile(f)) {
+            JarContents jarContents = new JarContents(jarFile);
+
+            Assert.assertTrue(jarContents.mightContainResource("current.txt"));
+            Assert.assertTrue(jarContents.mightContainResource(later));
+        }
+    }
+
+    @Test
+    public void testLeadingZeroVersion() throws Exception {
+        // The JDK does not treat a version directory with a leading zero as a
+        // versioned entry so the entry stays under its full name.
+        File f = createJar("META-INF/versions/09/zero.txt");
+
+        try (JarFile jarFile = new JarFile(f)) {
+            JarContents jarContents = new JarContents(jarFile);
+
+            
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/09/zero.txt"));
+        }
+    }
+
+    @Test
+    public void testEntryDirectlyBelowVersionsDirectory() throws Exception {
+        // There is no version directory so the entry stays under its full
+        // name and building the filter must not fail.
+        File f = createJar("META-INF/versions/Foo.class", "real.txt");
+
+        try (JarFile jarFile = new JarFile(f)) {
+            JarContents jarContents = new JarContents(jarFile);
+
+            
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/Foo.class"));
+            Assert.assertTrue(jarContents.mightContainResource("real.txt"));
+        }
+    }
+
+    private static File createJar(String... names) throws Exception {
+        File f = File.createTempFile("test-jar-contents", ".jar");
+        f.deleteOnExit();
+        try (ZipOutputStream out = new ZipOutputStream(new 
FileOutputStream(f))) {
+            for (String name : names) {
+                out.putNextEntry(new ZipEntry(name));
+                out.write("x".getBytes(StandardCharsets.UTF_8));
+                out.closeEntry();
+            }
+        }
+        return f;
+    }
 }
diff --git a/test/org/apache/catalina/webresources/TestJarWarResourceSet.java 
b/test/org/apache/catalina/webresources/TestJarWarResourceSet.java
index a93ad83fa0..b3a8631d3c 100644
--- a/test/org/apache/catalina/webresources/TestJarWarResourceSet.java
+++ b/test/org/apache/catalina/webresources/TestJarWarResourceSet.java
@@ -140,14 +140,96 @@ public class TestJarWarResourceSet extends TomcatBaseTest 
{
         resourceSet.gc();
     }
 
+    @Test
+    public void testMultiReleaseVersionDirectoryRules() throws Exception {
+        // Version directories with a sign or a leading zero are not version
+        // directories as far as the JDK is concerned. An entry directly below
+        // META-INF/versions/ has no version directory at all. All of these
+        // stay in the index under their full name. A leading slash is not
+        // significant when identifying a versioned entry.
+        File warFile = File.createTempFile("test-jar-war-resource-set", 
".war");
+        warFile.deleteOnExit();
+        try (ZipOutputStream out = new ZipOutputStream(new 
FileOutputStream(warFile))) {
+            out.putNextEntry(new ZipEntry("WEB-INF/lib/test.jar"));
+            out.write(createJar("META-INF/versions/+9/plus.txt", 
"META-INF/versions/-9/minus.txt",
+                    "META-INF/versions/09/zero.txt", 
"META-INF/versions/Foo.class",
+                    "/META-INF/versions/9/lead.txt", 
"META-INF/versions/9/ok.txt", "real.txt"));
+            out.closeEntry();
+        }
+
+        StandardRoot resourceRoot = new StandardRoot();
+        resourceRoot.setArchiveIndexStrategy("BLOOM");
+        JarWarResourceSet resourceSet = new JarWarResourceSet(resourceRoot, 
"/WEB-INF/classes",
+                warFile.getAbsolutePath(), "WEB-INF/lib/test.jar", "/");
+
+        Map<String,JarEntry> entries = resourceSet.getArchiveEntries(true);
+
+        
Assert.assertTrue(entries.containsKey("META-INF/versions/+9/plus.txt"));
+        Assert.assertFalse(entries.containsKey("plus.txt"));
+        
Assert.assertTrue(entries.containsKey("META-INF/versions/-9/minus.txt"));
+        Assert.assertFalse(entries.containsKey("minus.txt"));
+        
Assert.assertTrue(entries.containsKey("META-INF/versions/09/zero.txt"));
+        Assert.assertFalse(entries.containsKey("zero.txt"));
+        Assert.assertTrue(entries.containsKey("META-INF/versions/Foo.class"));
+        Assert.assertTrue(entries.containsKey("lead.txt"));
+        Assert.assertTrue(entries.containsKey("ok.txt"));
+        Assert.assertTrue(entries.containsKey("real.txt"));
+
+        // The bloom filter has to agree with the index
+        JarContents jarContents = resourceSet.jarContents;
+        Assert.assertNotNull(jarContents);
+        
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/+9/plus.txt"));
+        
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/-9/minus.txt"));
+        
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/09/zero.txt"));
+        
Assert.assertTrue(jarContents.mightContainResource("META-INF/versions/Foo.class"));
+        Assert.assertTrue(jarContents.mightContainResource("lead.txt"));
+        Assert.assertTrue(jarContents.mightContainResource("ok.txt"));
+
+        
Assert.assertTrue(resourceSet.getResource("/WEB-INF/classes/META-INF/versions/+9/plus.txt").exists());
+        
Assert.assertTrue(resourceSet.getResource("/WEB-INF/classes/META-INF/versions/09/zero.txt").exists());
+        
Assert.assertTrue(resourceSet.getResource("/WEB-INF/classes/META-INF/versions/Foo.class").exists());
+        
Assert.assertTrue(resourceSet.getResource("/WEB-INF/classes/lead.txt").exists());
+
+        resourceSet.gc();
+    }
+
+    @Test
+    public void testMultiReleaseVersionDirectoryEntry() throws Exception {
+        // The entry for the version directory itself has no resource name so
+        // it must not be added to the index as an entry with an empty name.
+        File warFile = File.createTempFile("test-jar-war-resource-set", 
".war");
+        warFile.deleteOnExit();
+        try (ZipOutputStream out = new ZipOutputStream(new 
FileOutputStream(warFile))) {
+            out.putNextEntry(new ZipEntry("WEB-INF/lib/test.jar"));
+            out.write(createJar("META-INF/versions/9/", 
"META-INF/versions/9/ok.txt", "real.txt"));
+            out.closeEntry();
+        }
+
+        StandardRoot resourceRoot = new StandardRoot();
+        resourceRoot.setArchiveIndexStrategy("BLOOM");
+        JarWarResourceSet resourceSet = new JarWarResourceSet(resourceRoot, 
"/WEB-INF/classes",
+                warFile.getAbsolutePath(), "WEB-INF/lib/test.jar", "/");
+
+        Map<String,JarEntry> entries = resourceSet.getArchiveEntries(true);
+
+        Assert.assertFalse(entries.containsKey(""));
+        Assert.assertTrue(entries.containsKey("ok.txt"));
+        Assert.assertTrue(entries.containsKey("real.txt"));
+        
Assert.assertTrue(resourceSet.getResource("/WEB-INF/classes/ok.txt").exists());
+
+        resourceSet.gc();
+    }
+
     private static byte[] createMultiReleaseJar() throws IOException {
+        return createJar("META-INF/versions/9/ok.txt", 
"META-INF/versions/9x/foo.txt",
+                "META-INF/versions//bar.txt", 
"META-INF/versions/99999999999999/ovf.txt", "real.txt");
+    }
+
+    private static byte[] createJar(String... names) throws IOException {
         Manifest manifest = new Manifest();
         manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, 
"1.0");
         manifest.getMainAttributes().putValue("Multi-Release", "true");
 
-        String[] names = { "META-INF/versions/9/ok.txt", 
"META-INF/versions/9x/foo.txt",
-                "META-INF/versions//bar.txt", 
"META-INF/versions/99999999999999/ovf.txt", "real.txt" };
-
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
         try (JarOutputStream jos = new JarOutputStream(baos, manifest)) {
             for (String name : names) {
diff --git a/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java 
b/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java
index 3ffe7eebd9..5db1bf3740 100644
--- a/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java
+++ b/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java
@@ -18,12 +18,22 @@ 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.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.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
 import org.apache.catalina.util.IOTools;
 import org.apache.catalina.webresources.TomcatURLStreamHandlerFactory;
@@ -31,6 +41,12 @@ import org.apache.tomcat.Jar;
 
 public class TestAbstractInputStreamJar {
 
+    private static final String VERSIONS = "META-INF/versions/";
+    private static final String BASE_CLASS = "test/Foo.class";
+
+    @Rule
+    public TemporaryFolder testDir = new TemporaryFolder();
+
     @Before
     public void register() {
         TomcatURLStreamHandlerFactory.register();
@@ -56,4 +72,210 @@ public class TestAbstractInputStreamJar {
 
         Assert.assertArrayEquals(baos1.toByteArray(), baos2.toByteArray());
     }
+
+
+    /*
+     * Creates a multi-release JAR. The content of each entry is its own name 
so the entry that was read can be
+     * identified.
+     */
+    private Jar createJar(String... entryNames) throws IOException {
+        return createJar(true, entryNames);
+    }
+
+    private Jar createJar(boolean multiRelease, String... entryNames) throws 
IOException {
+        Manifest manifest = new Manifest();
+        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, 
"1.0");
+        if (multiRelease) {
+            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(entryName.getBytes("ISO-8859-1"));
+                jos.closeEntry();
+            }
+        }
+        return new UrlJar(URI.create("jar:" + 
file.toURI().toURL().toExternalForm() + "!/").toURL());
+    }
+
+    private static String read(Jar jar, String name) throws IOException {
+        try (InputStream is = jar.getInputStream(name)) {
+            if (is == null) {
+                return null;
+            }
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            IOTools.flow(is, baos);
+            return baos.toString("ISO-8859-1");
+        }
+    }
+
+    private static List<String> entryNames(Jar jar) {
+        List<String> names = new ArrayList<>();
+        jar.nextEntry();
+        while (jar.getEntryName() != null) {
+            names.add(jar.getEntryName());
+            jar.nextEntry();
+        }
+        return names;
+    }
+
+
+    @Test
+    public void testMultiReleaseVersionSelected() throws Exception {
+        String v9 = VERSIONS + "9/" + BASE_CLASS;
+        String v11 = VERSIONS + "11/" + BASE_CLASS;
+        try (Jar jar = createJar(v9, v11, BASE_CLASS)) {
+            // Requests for the base name are served from the highest 
applicable version
+            Assert.assertEquals(v11, read(jar, BASE_CLASS));
+
+            jar.reset();
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(v11));
+            Assert.assertFalse(names.contains(v9));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * A leading '/' is not significant when identifying a versioned entry. 
The base entry must remain reachable and
+     * must be served from the versioned entry, using the entry's real name.
+     */
+    @Test
+    public void testMultiReleaseLeadingSlash() throws Exception {
+        String versioned = "/" + VERSIONS + "9/" + BASE_CLASS;
+        try (Jar jar = createJar(versioned, BASE_CLASS)) {
+            Assert.assertEquals(versioned, read(jar, BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * Version directories that are not used by the JDK (sign, leading zero, 
below the minimum, not a number) are not
+     * versioned entries. The base entry must be served from the base entry.
+     */
+    @Test
+    public void testMultiReleaseIgnoredVersionDirectories() throws Exception {
+        String[] versions = { "+9", "-9", "09", "7", "1", "abc", 
"99999999999999" };
+        for (String version : versions) {
+            try (Jar jar = createJar(VERSIONS + version + "/" + BASE_CLASS, 
BASE_CLASS)) {
+                Assert.assertEquals(version, BASE_CLASS, read(jar, 
BASE_CLASS));
+
+                jar.reset();
+                Assert.assertTrue(version, 
entryNames(jar).contains(BASE_CLASS));
+            }
+        }
+    }
+
+
+    @Test
+    public void testMultiReleaseEntryDirectlyBelowVersionsDirectory() throws 
Exception {
+        try (Jar jar = createJar(VERSIONS + "Foo.class", VERSIONS + "9/" + 
BASE_CLASS, BASE_CLASS)) {
+            Assert.assertEquals(VERSIONS + "9/" + BASE_CLASS, read(jar, 
BASE_CLASS));
+
+            jar.reset();
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(VERSIONS + "9/" + BASE_CLASS));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * Entries below META-INF/versions/ that are not versioned entries (the 
version directory is a sign, has a leading
+     * zero, is below the minimum, is not a number, or is missing) are 
ordinary entries. They must be returned under
+     * their full name, as JarFileUrlJar does, and must be readable by that 
name.
+     */
+    @Test
+    public void testMultiReleaseUnversionedEntriesAreReturned() throws 
Exception {
+        String[] unversioned = { VERSIONS + "+9/" + BASE_CLASS, VERSIONS + 
"-9/" + BASE_CLASS,
+                VERSIONS + "09/" + BASE_CLASS, VERSIONS + "7/" + BASE_CLASS, 
VERSIONS + "abc/" + BASE_CLASS,
+                VERSIONS + "99999999999999/" + BASE_CLASS, VERSIONS + 
"Foo.class" };
+
+        for (String entryName : unversioned) {
+            try (Jar jar = createJar(entryName, BASE_CLASS)) {
+                List<String> names = entryNames(jar);
+                Assert.assertTrue(entryName, names.contains(entryName));
+                Assert.assertTrue(entryName, names.contains(BASE_CLASS));
+
+                Assert.assertEquals(entryName, entryName, read(jar, 
entryName));
+                Assert.assertEquals(entryName, BASE_CLASS, read(jar, 
BASE_CLASS));
+            }
+        }
+    }
+
+
+    /*
+     * Nothing is versioned in a JAR that is not a multi-release JAR. Every 
entry must be returned, in order, and every
+     * entry must be readable by name, including those below 
META-INF/versions/.
+     */
+    @Test
+    public void testNotMultiRelease() throws Exception {
+        String versioned = VERSIONS + "9/" + BASE_CLASS;
+        String[] entryNames = { "a.txt", "b.txt", versioned, BASE_CLASS, 
"c.txt", "d.txt", "e.txt" };
+
+        try (Jar jar = createJar(false, entryNames)) {
+            Assert.assertEquals(List.of(entryNames), entryNames(jar));
+
+            for (String entryName : entryNames) {
+                Assert.assertEquals(entryName, entryName, read(jar, 
entryName));
+            }
+
+            jar.reset();
+            Assert.assertEquals(List.of(entryNames), entryNames(jar));
+        }
+    }
+
+
+    /*
+     * An entry for a lower version is not used when a higher version is 
available. That must also be the case when the
+     * entry name has a leading '/'.
+     */
+    @Test
+    public void testMultiReleaseUnselectedLeadingSlashEntryIsHidden() throws 
Exception {
+        String lower = "/" + VERSIONS + "9/" + BASE_CLASS;
+        String higher = VERSIONS + "11/" + BASE_CLASS;
+        try (Jar jar = createJar(lower, higher, BASE_CLASS)) {
+            Assert.assertEquals(higher, read(jar, BASE_CLASS));
+
+            jar.reset();
+            Assert.assertEquals(List.of(higher), entryNames(jar));
+        }
+    }
+
+
+    /*
+     * Multi-release applies to all types of resource, not just classes. The 
base name must be served from the highest
+     * version that applies to the current runtime.
+     */
+    @Test
+    public void testMultiReleaseVersionedResourceIsUsed() throws Exception {
+        String base = "test/resource.txt";
+        String v9 = VERSIONS + "9/" + base;
+        String v11 = VERSIONS + "11/" + base;
+        try (Jar jar = createJar(v9, v11, base)) {
+            Assert.assertEquals(v11, read(jar, base));
+
+            jar.reset();
+            Assert.assertEquals(List.of(v11), entryNames(jar));
+        }
+    }
+
+
+    /*
+     * A resource that only exists in the versions location is available under 
its base name.
+     */
+    @Test
+    public void testMultiReleaseVersionedResourceWithoutBaseEntry() throws 
Exception {
+        String base = "test/resource.txt";
+        String v9 = VERSIONS + "9/" + base;
+        try (Jar jar = createJar(v9)) {
+            Assert.assertEquals(v9, read(jar, base));
+
+            jar.reset();
+            Assert.assertEquals(List.of(v9), entryNames(jar));
+        }
+    }
 }
diff --git a/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java 
b/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java
index 9165d5e70c..6b69698491 100644
--- a/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java
+++ b/test/org/apache/tomcat/util/scan/TestJarFileUrlJar.java
@@ -182,9 +182,8 @@ public class TestJarFileUrlJar {
 
     /*
      * 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.
+     * parsed must be treated as normal entries. Versioned entries for 
resources (non-class entries) are handled the
+     * same way as versioned classes: the highest applicable version is used 
in place of the base entry.
      */
     @Test
     public void testMultiReleaseResetAndUnusableEntries() throws Exception {
@@ -196,12 +195,12 @@ public class TestJarFileUrlJar {
                 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.assertTrue(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));
+            Assert.assertTrue(names.contains(versionedResource));
+            Assert.assertFalse(names.contains(resource));
 
             jar.reset();
             Assert.assertEquals(names, entryNames(jar));
@@ -223,4 +222,191 @@ public class TestJarFileUrlJar {
             Assert.assertFalse(names.contains(BASE_CLASS));
         }
     }
+
+
+    /*
+     * An entry directly below META-INF/versions/ has no version directory. It 
must not stop the JAR being scanned and
+     * is returned under its full name like any other entry that is not a 
versioned entry.
+     */
+    @Test
+    public void testMultiReleaseEntryDirectlyBelowVersionsDirectory() throws 
Exception {
+        File file = createMultiReleaseJar(VERSIONS + "Foo.class", VERSIONS + 
"9/" + BASE_CLASS, BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(VERSIONS + "Foo.class"));
+            Assert.assertTrue(names.contains(VERSIONS + "9/" + BASE_CLASS));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * Version directories with a sign or a leading zero are not version 
directories as far as the JDK is concerned.
+     * The entries below them are ordinary entries so they are returned under 
their full name and must not be selected
+     * in place of the base entry.
+     */
+    @Test
+    public void testMultiReleaseSignedAndLeadingZeroVersionsAreNotVersioned() 
throws Exception {
+        String[] versions = { "+9", "-9", "09" };
+        for (String version : versions) {
+            File file = createMultiReleaseJar(VERSIONS + version + "/" + 
BASE_CLASS, BASE_CLASS);
+            try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+                List<String> names = entryNames(jar);
+                Assert.assertTrue(version, names.contains(VERSIONS + version + 
"/" + BASE_CLASS));
+                Assert.assertTrue(version, names.contains(BASE_CLASS));
+            }
+        }
+    }
+
+
+    /*
+     * A leading '/' is not significant when identifying a versioned entry.
+     */
+    @Test
+    public void testMultiReleaseLeadingSlash() throws Exception {
+        String versioned = "/" + VERSIONS + "9/" + BASE_CLASS;
+        File file = createMultiReleaseJar(versioned, BASE_CLASS);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(versioned));
+            Assert.assertFalse(names.contains(BASE_CLASS));
+        }
+    }
+
+
+    /*
+     * Multi-release applies to all types of resource, not just classes. The 
entry selected 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 testMultiReleaseVersionSelectionForResources() throws 
Exception {
+        String base = "test/resource.txt";
+        String v9 = VERSIONS + "9/" + base;
+        String v11 = VERSIONS + "11/" + base;
+
+        File file = createMultiReleaseJar(v9, v11, base);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(v11));
+            Assert.assertFalse(names.contains(v9));
+            Assert.assertFalse(names.contains(base));
+        }
+
+        file = createMultiReleaseJar(base, v11, v9);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            List<String> names = entryNames(jar);
+            Assert.assertTrue(names.contains(v11));
+            Assert.assertFalse(names.contains(v9));
+            Assert.assertFalse(names.contains(base));
+        }
+    }
+
+
+    /*
+     * A resource that only exists in the versions location is available.
+     */
+    @Test
+    public void testMultiReleaseVersionedResourceWithoutBaseEntry() throws 
Exception {
+        String v9 = VERSIONS + "9/test/resource.txt";
+
+        File file = createMultiReleaseJar(v9);
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            Assert.assertTrue(entryNames(jar).contains(v9));
+        }
+    }
+
+
+    /*
+     * Creates a multi-release JAR where the content of each entry is its own 
name so the entry that was selected can be
+     * identified from the content.
+     */
+    private File createMultiReleaseJarWithNameContent(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(), "named.jar");
+        try (JarOutputStream jos = new JarOutputStream(new 
FileOutputStream(file), manifest)) {
+            for (String entryName : entryNames) {
+                jos.putNextEntry(new JarEntry(entryName));
+                jos.write(entryName.getBytes("ISO-8859-1"));
+                jos.closeEntry();
+            }
+        }
+        return file;
+    }
+
+
+    private static String read(Jar jar, String name) throws IOException {
+        try (InputStream is = jar.getInputStream(name)) {
+            return is == null ? null : new String(is.readAllBytes(), 
"ISO-8859-1");
+        }
+    }
+
+
+    /*
+     * Look-ups by name must be multi-release aware for every type of 
resource: the highest version that applies to the
+     * current runtime is used, versions for later runtimes are ignored and a 
resource that only exists in the versions
+     * location is available.
+     */
+    private void doTestMultiReleaseLookupByName(Jar jar) throws IOException {
+        String resource = "test/resource.txt";
+        String v11Resource = VERSIONS + "11/" + resource;
+        String only9 = "only9.txt";
+        String v9Only9 = VERSIONS + "9/" + only9;
+        String laterVersion = "later.txt";
+
+        // Highest applicable version used in place of the base entry or a 
lower version
+        Assert.assertEquals(v11Resource, read(jar, resource));
+        Assert.assertTrue(jar.exists(resource));
+        Assert.assertTrue(jar.getLastModified(resource) != -1);
+
+        // Class
+        Assert.assertEquals(VERSIONS + "11/" + BASE_CLASS, read(jar, 
BASE_CLASS));
+
+        // Only in the versions location
+        Assert.assertEquals(v9Only9, read(jar, only9));
+        Assert.assertTrue(jar.exists(only9));
+        Assert.assertTrue(jar.getLastModified(only9) != -1);
+
+        // Version for a later runtime ignored
+        Assert.assertEquals(laterVersion, read(jar, laterVersion));
+
+        // Not present at all
+        Assert.assertNull(read(jar, "missing.txt"));
+        Assert.assertFalse(jar.exists("missing.txt"));
+        Assert.assertEquals(-1, jar.getLastModified("missing.txt"));
+
+        // Entry that only exists for a later runtime is not available
+        Assert.assertNull(read(jar, "onlyLater.txt"));
+        Assert.assertFalse(jar.exists("onlyLater.txt"));
+    }
+
+
+    private File createLookupJar() throws IOException {
+        String resource = "test/resource.txt";
+        return createMultiReleaseJarWithNameContent(resource, VERSIONS + "9/" 
+ resource, VERSIONS + "11/" + resource,
+                BASE_CLASS, VERSIONS + "9/" + BASE_CLASS, VERSIONS + "11/" + 
BASE_CLASS,
+                VERSIONS + "9/only9.txt", "later.txt", VERSIONS + 
"999/later.txt",
+                VERSIONS + "999/onlyLater.txt");
+    }
+
+
+    @Test
+    public void testMultiReleaseLookupByName() throws Exception {
+        File file = createLookupJar();
+        try (Jar jar = new JarFileUrlJar(file.toURI().toURL(), false)) {
+            doTestMultiReleaseLookupByName(jar);
+        }
+    }
+
+
+    @Test
+    public void testMultiReleaseLookupByNameViaJarUrl() throws Exception {
+        File file = createLookupJar();
+        URL url = URI.create("jar:" + file.toURI().toURL().toExternalForm() + 
"!/").toURL();
+        try (Jar jar = new JarFileUrlJar(url, true)) {
+            doTestMultiReleaseLookupByName(jar);
+        }
+    }
 }
diff --git a/test/org/apache/tomcat/util/scan/TestMultiVersionUtil.java 
b/test/org/apache/tomcat/util/scan/TestMultiVersionUtil.java
new file mode 100644
index 0000000000..3156bdf39d
--- /dev/null
+++ b/test/org/apache/tomcat/util/scan/TestMultiVersionUtil.java
@@ -0,0 +1,134 @@
+/*
+ * 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 org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.tomcat.util.scan.MultiReleaseUtil.MultiVersionInfo;
+
+public class TestMultiVersionUtil {
+
+    @Test
+    public void testValidVersion() {
+        MultiVersionInfo info = 
MultiReleaseUtil.parseVersion("META-INF/versions/9/test.txt");
+        Assert.assertNotNull(info);
+        Assert.assertTrue(info.isValid());
+        Assert.assertEquals(9, info.version());
+        Assert.assertEquals("test.txt", 
"META-INF/versions/9/test.txt".substring(info.namePos()));
+    }
+
+
+    /*
+     * The JDK does not treat a version directory with a leading zero as a 
versioned entry (it resolves the base name
+     * to the base entry, not to the entry below META-INF/versions/09/) so 
neither should Tomcat.
+     */
+    @Test
+    public void testLeadingZeroVersionIsInvalid() {
+        MultiVersionInfo info = 
MultiReleaseUtil.parseVersion("META-INF/versions/09/test.txt");
+        Assert.assertNotNull(info);
+        Assert.assertFalse(info.isValid());
+    }
+
+
+    @Test
+    public void testCurrentRuntimeVersion() {
+        int feature = Runtime.version().feature();
+        MultiVersionInfo info = 
MultiReleaseUtil.parseVersion("META-INF/versions/" + feature + "/test.txt");
+        Assert.assertNotNull(info);
+        Assert.assertTrue(info.isValid());
+        Assert.assertEquals(feature, info.version());
+    }
+
+
+    @Test
+    public void testNotVersioned() {
+        Assert.assertNull(MultiReleaseUtil.parseVersion(null));
+        Assert.assertNull(MultiReleaseUtil.parseVersion(""));
+        Assert.assertNull(MultiReleaseUtil.parseVersion("test/Foo.class"));
+        
Assert.assertNull(MultiReleaseUtil.parseVersion("META-INF/MANIFEST.MF"));
+        // No trailing '/' so not the versions directory
+        Assert.assertNull(MultiReleaseUtil.parseVersion("META-INF/versions"));
+        
Assert.assertNull(MultiReleaseUtil.parseVersion("other/META-INF/versions/9/test.txt"));
+    }
+
+
+    @Test
+    public void testLeadingSlash() {
+        String name = "/META-INF/versions/9/test.txt";
+        MultiVersionInfo info = MultiReleaseUtil.parseVersion(name);
+        Assert.assertNotNull(info);
+        Assert.assertTrue(info.isValid());
+        Assert.assertEquals(9, info.version());
+        Assert.assertEquals("test.txt", name.substring(info.namePos()));
+    }
+
+
+    @Test
+    public void testDirectoryEntry() {
+        String name = "META-INF/versions/9/";
+        MultiVersionInfo info = MultiReleaseUtil.parseVersion(name);
+        Assert.assertNotNull(info);
+        Assert.assertTrue(info.isValid());
+        Assert.assertEquals(name.length(), info.namePos());
+    }
+
+
+    @Test
+    public void testInvalidVersions() {
+        String[] versions = { "+9", "-9", "9x", "x9", "9.0", "", "0", "09", 
"00", "99999999999999", "2147483648" };
+        for (String version : versions) {
+            String name = "META-INF/versions/" + version + "/test.txt";
+            MultiVersionInfo info = MultiReleaseUtil.parseVersion(name);
+            Assert.assertNotNull(version, info);
+            Assert.assertFalse(version, info.isValid());
+            // The position of the resource name is still reported so callers 
can keep the entry under its full name
+            Assert.assertEquals(version, "test.txt", 
name.substring(info.namePos()));
+        }
+    }
+
+
+    @Test
+    public void testNoSeparatorAfterVersionsDirectory() {
+        MultiVersionInfo info = 
MultiReleaseUtil.parseVersion("META-INF/versions/Foo.class");
+        Assert.assertNotNull(info);
+        Assert.assertFalse(info.isValid());
+        Assert.assertEquals(-1, info.namePos());
+    }
+
+
+    /*
+     * Java 21 onwards treats 8 as the lowest version directory that is used. 
Lower versions are ignored.
+     */
+    @Test
+    public void testMinimumVersion() {
+        for (int version = 1; version < 8; version++) {
+            String name = "META-INF/versions/" + version + "/test.txt";
+            MultiVersionInfo info = MultiReleaseUtil.parseVersion(name);
+            Assert.assertNotNull(Integer.toString(version), info);
+            Assert.assertFalse(Integer.toString(version), info.isValid());
+            Assert.assertEquals(Integer.toString(version), "test.txt", 
name.substring(info.namePos()));
+        }
+
+        for (int version = 8; version <= 10; version++) {
+            MultiVersionInfo info = 
MultiReleaseUtil.parseVersion("META-INF/versions/" + version + "/test.txt");
+            Assert.assertNotNull(Integer.toString(version), info);
+            Assert.assertTrue(Integer.toString(version), info.isValid());
+            Assert.assertEquals(version, info.version());
+        }
+    }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index bf8ee6ea00..f63cd8e5c0 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -484,6 +484,13 @@
         <code>TIME</code> is now of the form <code>yyyyMMddHHmmss</code>
         rather than an HTTP date.
       </fix>
+      <fix>
+        Refactor to reduce duplication and improve consistency when working 
with
+        resources in multi-release JAR files. Versions that include signs,
+        leading zeros or values less than 8 are no longer considered valid. All
+        types of resources, not just class files, are now handled in
+        multi-release JAR files. (markt)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Coyote">


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

Reply via email to