This is an automated email from the ASF dual-hosted git repository. markt-asf pushed a commit to branch 10.1.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit 4c210ef903de06943b57cd4f45bf055cbca82b30 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 | 108 ++++++++++ .../catalina/webresources/TestJarContents.java | 65 +++++- .../webresources/TestJarWarResourceSet.java | 88 +++++++- .../util/scan/TestAbstractInputStreamJar.java | 223 +++++++++++++++++++++ .../apache/tomcat/util/scan/TestJarFileUrlJar.java | 198 +++++++++++++++++- .../tomcat/util/scan/TestMultiVersionUtil.java | 134 +++++++++++++ webapps/docs/changelog.xml | 7 + 11 files changed, 910 insertions(+), 135 deletions(-) diff --git a/java/org/apache/catalina/webresources/JarContents.java b/java/org/apache/catalina/webresources/JarContents.java index abb13c93b0..5ce052f21a 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 c484821850..2b2e7c2cd4 100644 --- a/java/org/apache/catalina/webresources/JarWarResourceSet.java +++ b/java/org/apache/catalina/webresources/JarWarResourceSet.java @@ -34,6 +34,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. @@ -191,53 +193,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.getVersion()) { - // 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.getVersion()) { + // 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().getJarEntry()); diff --git a/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java b/java/org/apache/tomcat/util/scan/AbstractInputStreamJar.java index cfd80056a3..a1b4e5add7 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..41b9b19a13 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..81ad71768f --- /dev/null +++ b/java/org/apache/tomcat/util/scan/MultiReleaseUtil.java @@ -0,0 +1,108 @@ +/* + * 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)}. + */ + public static class MultiVersionInfo { + + public final int version; + public final int namePos; + + /** + * Create a new result. + * + * @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 MultiVersionInfo(int version, int namePos) { + this.version = version; + this.namePos = 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 f2b6314ec3..95d0187594 100644 --- a/test/org/apache/catalina/webresources/TestJarContents.java +++ b/test/org/apache/catalina/webresources/TestJarContents.java @@ -155,10 +155,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 b6f96e2b17..9803698eea 100644 --- a/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java +++ b/test/org/apache/tomcat/util/scan/TestAbstractInputStreamJar.java @@ -18,12 +18,23 @@ 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.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 +42,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 +73,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..5046ba8b36 --- /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 e9f43dead9..bb3b2c6a94 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -304,6 +304,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]
