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/<version>/</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]
