This is an automated email from the ASF dual-hosted git repository. rmaucher pushed a commit to branch 11.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit 679b7107fd0e0dd068026d9863cb4549b6418bee Author: opencode <[email protected]> AuthorDate: Fri Oct 16 13:16:24 2026 +0200 Keep streams returned from ReferenceCountedJar open The stream-returning methods released the reference they opened before the caller could use the returned stream, so when no other reference was held the wrapped JAR was closed and the stream could no longer be read. They now transfer the opened reference to the returned stream: it is released when the stream is closed, exactly once, so the wrapped JAR stays open for the lifetime of the stream. This matches the Jar contract under which the caller is responsible for closing streams. A null stream and failed look-ups release the reference immediately. --- .../tomcat/util/scan/ReferenceCountedJar.java | 48 +++++- .../tomcat/util/scan/TestReferenceCountedJar.java | 182 +++++++++++++++++++++ 2 files changed, 226 insertions(+), 4 deletions(-) diff --git a/java/org/apache/tomcat/util/scan/ReferenceCountedJar.java b/java/org/apache/tomcat/util/scan/ReferenceCountedJar.java index a774104af9..83f3dc1bce 100644 --- a/java/org/apache/tomcat/util/scan/ReferenceCountedJar.java +++ b/java/org/apache/tomcat/util/scan/ReferenceCountedJar.java @@ -16,6 +16,7 @@ */ package org.apache.tomcat.util.scan; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; @@ -68,6 +69,37 @@ public class ReferenceCountedJar implements Jar { } + /* + * Transfers the reference obtained by open() to the given stream obtained from the wrapped Jar. The reference is + * released when - and only when - the stream is closed, so the wrapped Jar remains open while the stream is in + * use. A null stream releases the reference immediately. + */ + private InputStream retainReference(InputStream is) { + if (is == null) { + close(); + return null; + } + + return new FilterInputStream(is) { + + private boolean released = false; + + @Override + public synchronized void close() throws IOException { + if (released) { + return; + } + released = true; + try { + in.close(); + } finally { + ReferenceCountedJar.this.close(); + } + } + }; + } + + @Override public URL getJarFileURL() { return url; @@ -76,8 +108,12 @@ public class ReferenceCountedJar implements Jar { @Override public InputStream getInputStream(String name) throws IOException { - try (ReferenceCountedJar jar = open()) { - return jar.wrappedJar.getInputStream(name); + ReferenceCountedJar jar = open(); + try { + return jar.retainReference(jar.wrappedJar.getInputStream(name)); + } catch (IOException | RuntimeException e) { + jar.close(); + throw e; } } @@ -120,8 +156,12 @@ public class ReferenceCountedJar implements Jar { @Override public InputStream getEntryInputStream() throws IOException { - try (ReferenceCountedJar jar = open()) { - return jar.wrappedJar.getEntryInputStream(); + ReferenceCountedJar jar = open(); + try { + return jar.retainReference(jar.wrappedJar.getEntryInputStream()); + } catch (IOException | RuntimeException e) { + jar.close(); + throw e; } } diff --git a/test/org/apache/tomcat/util/scan/TestReferenceCountedJar.java b/test/org/apache/tomcat/util/scan/TestReferenceCountedJar.java new file mode 100644 index 0000000000..77a6c8a117 --- /dev/null +++ b/test/org/apache/tomcat/util/scan/TestReferenceCountedJar.java @@ -0,0 +1,182 @@ +/* + * 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.nio.charset.StandardCharsets; +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.webresources.TomcatURLStreamHandlerFactory; + +public class TestReferenceCountedJar { + + @Rule + public TemporaryFolder testDir = new TemporaryFolder(); + + private static final String ENTRY_NAME = "test/resource.txt"; + private static final String CONTENT = "hello"; + + @Before + public void register() { + TomcatURLStreamHandlerFactory.register(); + } + + + private File createJar() throws IOException { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + + File file = new File(testDir.getRoot(), "test.jar"); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(file), manifest)) { + jos.putNextEntry(new JarEntry(ENTRY_NAME)); + jos.write(CONTENT.getBytes(StandardCharsets.UTF_8)); + jos.closeEntry(); + } + return file; + } + + + private static String readFully(InputStream is) throws IOException { + 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("UTF-8"); + } + + + /* + * The reference obtained internally when a stream is opened must be held until the stream is closed. Without it, + * reading a stream obtained after the wrapper has been closed and re-opened (the bug 62976 scenario) would fail + * because the wrapped JAR is closed as soon as getInputStream() returns. + */ + @Test + public void testInputStreamRemainsValidWhenWrapperHasNoOtherReference() throws Exception { + File file = createJar(); + URI jarUri = URI.create("jar:" + file.toURI().toURL().toExternalForm() + "!/"); + + ReferenceCountedJar jar = new ReferenceCountedJar(jarUri.toURL()); + // Close the reference obtained by the constructor so the wrapper no longer holds any reference. + jar.close(); + + // This re-opens the wrapped JAR. The returned stream must stay usable even though no other reference is held. + try (InputStream is = jar.getInputStream(ENTRY_NAME)) { + Assert.assertNotNull(is); + Assert.assertEquals(CONTENT, readFully(is)); + } + + // Re-reading after the stream has been closed (releasing the reference it held) must still work. + try (InputStream is = jar.getInputStream(ENTRY_NAME)) { + Assert.assertEquals(CONTENT, readFully(is)); + } + } + + + /* + * When a caller holds a reference and iterates the entries (as jasper does when parsing tag files), the reference + * obtained for the current entry stream must be held until that stream is closed and must not, on close, release + * the caller's reference. + */ + @Test + public void testEntryInputStreamHoldsReferenceWhileIterating() throws Exception { + File file = createJar(); + URI jarUri = URI.create("jar:" + file.toURI().toURL().toExternalForm() + "!/"); + + // Retain the constructor reference, as a real caller does across nextEntry()/getEntryInputStream(). + try (ReferenceCountedJar jar = new ReferenceCountedJar(jarUri.toURL())) { + boolean found = false; + jar.nextEntry(); + while (jar.getEntryName() != null) { + if (ENTRY_NAME.equals(jar.getEntryName())) { + try (InputStream is = jar.getEntryInputStream()) { + Assert.assertEquals(CONTENT, readFully(is)); + } + found = true; + } + jar.nextEntry(); + } + Assert.assertTrue(found); + + // Closing the entry stream must not have released the caller's reference. + try (InputStream is = jar.getInputStream(ENTRY_NAME)) { + Assert.assertEquals(CONTENT, readFully(is)); + } + } + } + + + /* + * Closing the returned stream more than once must release the held reference only once so the reference count is + * not decremented below the value to which the close of the stream should return it. + */ + @Test + public void testStreamDoubleCloseReleasesReferenceOnce() throws Exception { + File file = createJar(); + URI jarUri = URI.create("jar:" + file.toURI().toURL().toExternalForm() + "!/"); + + // Here the constructor reference is retained, as a normal caller would do. + try (ReferenceCountedJar jar = new ReferenceCountedJar(jarUri.toURL())) { + InputStream is = jar.getInputStream(ENTRY_NAME); + is.close(); + is.close(); + + // The extra close must not have released the caller's reference. The JAR must still be readable through + // the caller's (still open) reference without being re-opened. + try (InputStream is2 = jar.getInputStream(ENTRY_NAME)) { + Assert.assertEquals(CONTENT, readFully(is2)); + } + } + } + + + /* + * A request for a stream for a name that does not exist must release the opened reference immediately and return + * null rather than holding a reference that is never released. + */ + @Test + public void testMissingEntryReleasesReference() throws Exception { + File file = createJar(); + URI jarUri = URI.create("jar:" + file.toURI().toURL().toExternalForm() + "!/"); + + ReferenceCountedJar jar = new ReferenceCountedJar(jarUri.toURL()); + jar.close(); + + Assert.assertNull(jar.getInputStream("test/does-not-exist.txt")); + + // The failed request must not have left a reference held: a later successful request must still work. + try (InputStream is = jar.getInputStream(ENTRY_NAME)) { + Assert.assertEquals(CONTENT, readFully(is)); + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
