8367561: Getting some "header" property from a file:// URL causes a file descriptor leak

Reviewed-by: dfuchs, vyazici
This commit is contained in:
Jaikiran Pai 2025-10-29 11:19:53 +00:00
parent 3cbcda5ff3
commit 4a0200caf9
4 changed files with 443 additions and 37 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 1995, 2023, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 1995, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
@ -101,9 +101,21 @@ public abstract class URLConnection extends java.net.URLConnection {
return Collections.emptyMap();
}
/**
* This method is called whenever the headers related methods are called on the
* {@code URLConnection}. This method does any necessary checks and initializations
* to make sure that the headers can be served. If this {@code URLConnection} cannot
* serve the headers, then this method throws an {@code IOException}.
*
* @throws IOException if the headers cannot be served
*/
protected void ensureCanServeHeaders() throws IOException {
getInputStream();
}
public String getHeaderField(String name) {
try {
getInputStream();
ensureCanServeHeaders();
} catch (Exception e) {
return null;
}
@ -111,13 +123,13 @@ public abstract class URLConnection extends java.net.URLConnection {
}
Map<String, List<String>> headerFields;
private Map<String, List<String>> headerFields;
@Override
public Map<String, List<String>> getHeaderFields() {
if (headerFields == null) {
try {
getInputStream();
ensureCanServeHeaders();
if (properties == null) {
headerFields = super.getHeaderFields();
} else {
@ -137,7 +149,7 @@ public abstract class URLConnection extends java.net.URLConnection {
*/
public String getHeaderFieldKey(int n) {
try {
getInputStream();
ensureCanServeHeaders();
} catch (Exception e) {
return null;
}
@ -152,7 +164,7 @@ public abstract class URLConnection extends java.net.URLConnection {
*/
public String getHeaderField(int n) {
try {
getInputStream();
ensureCanServeHeaders();
} catch (Exception e) {
return null;
}
@ -221,7 +233,7 @@ public abstract class URLConnection extends java.net.URLConnection {
*/
public int getContentLength() {
try {
getInputStream();
ensureCanServeHeaders();
} catch (Exception e) {
return -1;
}

View File

@ -25,15 +25,30 @@
package sun.net.www.protocol.file;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FilePermission;
import java.io.IOException;
import java.io.InputStream;
import java.net.FileNameMap;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.FileNameMap;
import java.io.*;
import java.text.Collator;
import java.security.Permission;
import sun.net.www.*;
import java.util.*;
import java.text.Collator;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import sun.net.www.MessageHeader;
import sun.net.www.ParseUtil;
import sun.net.www.URLConnection;
/**
* Open a file input stream given a URL.
@ -67,25 +82,49 @@ public class FileURLConnection extends URLConnection {
this.file = file;
}
/*
/**
* If already connected, then this method is a no-op.
* If not already connected, then this method does
* readability checks for the File.
* <p>
* If the File is a directory then the readability check
* is done by verifying that File.list() does not return
* null. On the other hand, if the File is not a directory,
* then this method constructs a temporary FileInputStream
* for the File and lets the FileInputStream's constructor
* implementation do the necessary readability checks.
* That temporary FileInputStream is closed before returning
* from this method.
* <p>
* In either case, if the readability checks fail, then
* an IOException is thrown from this method and the
* FileURLConnection stays unconnected.
* <p>
* A normal return from this method implies that the
* FileURLConnection is connected and the readability
* checks have passed for the File.
* <p>
* Note: the semantics of FileURLConnection object is that the
* results of the various URLConnection calls, such as
* getContentType, getInputStream or getContentLength reflect
* whatever was true when connect was called.
*/
@Override
public void connect() throws IOException {
if (!connected) {
isDirectory = file.isDirectory();
// verify readability of the directory or the regular file
if (isDirectory) {
String[] fileList = file.list();
if (fileList == null)
if (fileList == null) {
throw new FileNotFoundException(file.getPath() + " exists, but is not accessible");
}
directoryListing = Arrays.asList(fileList);
} else {
is = new BufferedInputStream(new FileInputStream(file.getPath()));
// let FileInputStream constructor do the necessary readability checks
// and propagate any failures
new FileInputStream(file.getPath()).close();
}
connected = true;
}
}
@ -112,9 +151,9 @@ public class FileURLConnection extends URLConnection {
FileNameMap map = java.net.URLConnection.getFileNameMap();
String contentType = map.getContentTypeFor(file.getPath());
if (contentType != null) {
properties.add(CONTENT_TYPE, contentType);
properties.set(CONTENT_TYPE, contentType);
}
properties.add(CONTENT_LENGTH, Long.toString(length));
properties.set(CONTENT_LENGTH, Long.toString(length));
/*
* Format the last-modified field into the preferred
@ -126,30 +165,34 @@ public class FileURLConnection extends URLConnection {
SimpleDateFormat fo =
new SimpleDateFormat ("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US);
fo.setTimeZone(TimeZone.getTimeZone("GMT"));
properties.add(LAST_MODIFIED, fo.format(date));
properties.set(LAST_MODIFIED, fo.format(date));
}
} else {
properties.add(CONTENT_TYPE, TEXT_PLAIN);
properties.set(CONTENT_TYPE, TEXT_PLAIN);
}
initializedHeaders = true;
}
}
public Map<String,List<String>> getHeaderFields() {
@Override
public Map<String, List<String>> getHeaderFields() {
initializeHeaders();
return super.getHeaderFields();
}
@Override
public String getHeaderField(String name) {
initializeHeaders();
return super.getHeaderField(name);
}
@Override
public String getHeaderField(int n) {
initializeHeaders();
return super.getHeaderField(n);
}
@Override
public int getContentLength() {
initializeHeaders();
if (length > Integer.MAX_VALUE)
@ -157,54 +200,74 @@ public class FileURLConnection extends URLConnection {
return (int) length;
}
@Override
public long getContentLengthLong() {
initializeHeaders();
return length;
}
@Override
public String getHeaderFieldKey(int n) {
initializeHeaders();
return super.getHeaderFieldKey(n);
}
@Override
public MessageHeader getProperties() {
initializeHeaders();
return super.getProperties();
}
@Override
public long getLastModified() {
initializeHeaders();
return lastModified;
}
@Override
public synchronized InputStream getInputStream()
throws IOException {
connect();
// connect() does the necessary readability checks and is expected to
// throw IOException if any of those checks fail. A normal completion of connect()
// must mean that connect succeeded.
assert connected : "not connected";
if (is == null) {
if (isDirectory) {
// a FileURLConnection only ever creates and provides a single InputStream
if (is != null) {
return is;
}
if (directoryListing == null) {
throw new FileNotFoundException(file.getPath());
}
if (isDirectory) {
// a successful connect() implies the directoryListing is non-null
// if the file is a directory
assert directoryListing != null : "missing directory listing";
directoryListing.sort(Collator.getInstance());
directoryListing.sort(Collator.getInstance());
StringBuilder sb = new StringBuilder();
for (String fileName : directoryListing) {
sb.append(fileName);
sb.append("\n");
}
// Put it into a (default) locale-specific byte-stream.
is = new ByteArrayInputStream(sb.toString().getBytes());
} else {
throw new FileNotFoundException(file.getPath());
StringBuilder sb = new StringBuilder();
for (String fileName : directoryListing) {
sb.append(fileName);
sb.append("\n");
}
// Put it into a (default) locale-specific byte-stream.
is = new ByteArrayInputStream(sb.toString().getBytes());
} else {
is = new BufferedInputStream(new FileInputStream(file.getPath()));
}
return is;
}
@Override
protected synchronized void ensureCanServeHeaders() throws IOException {
// connect() (if not already connected) does the readability checks
// and throws an IOException if those checks fail. A successful
// completion from connect() implies the File is readable.
connect();
}
Permission permission;
/* since getOutputStream isn't supported, only read permission is

View File

@ -0,0 +1,181 @@
/*
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.UnknownServiceException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*
* @test
* @bug 8367561
* @summary verify that the implementation of URLConnection APIs for "file:"
* protocol does not leak InputStream(s)
* @run junit/othervm ${test.main.class}
*/
class FileURLConnStreamLeakTest {
private static final String FILE_URLCONNECTION_CLASSNAME = "sun.net.www.protocol.file.FileURLConnection";
private Path testFile;
// FileInputStream has a Cleaner which closes its underlying file descriptor.
// Here we keep reference to the URLConnection for the duration of each test method.
// This ensures that any FileInputStream this URLConnection may be retaining, won't be GCed
// until after the test method has checked for file descriptor leaks.
private URLConnection conn;
@BeforeEach
void beforeEach() throws Exception {
final Path file = Files.createTempFile(Path.of("."), "8367561-", ".txt");
Files.writeString(file, String.valueOf(System.currentTimeMillis()));
this.testFile = file;
this.conn = this.testFile.toUri().toURL().openConnection();
assertNotNull(this.conn, "URLConnection for " + this.testFile + " is null");
assertEquals(FILE_URLCONNECTION_CLASSNAME, conn.getClass().getName(),
"unexpected URLConnection type");
}
@AfterEach
void afterEach() throws Exception {
this.conn = null;
// the file should already have been deleted by the test method
Files.deleteIfExists(this.testFile);
}
static List<Consumer<URLConnection>> urlConnOperations() {
return List.of(
URLConnection::getContentEncoding,
URLConnection::getContentLength,
URLConnection::getContentLengthLong,
URLConnection::getContentType,
URLConnection::getDate,
URLConnection::getExpiration,
URLConnection::getLastModified
);
}
@MethodSource("urlConnOperations")
@ParameterizedTest
void testURLConnOps(final Consumer<URLConnection> connConsumer) throws IOException {
connConsumer.accept(this.conn);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderField() throws Exception {
final var _ = this.conn.getHeaderField(0);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFieldString() throws Exception {
final String val = this.conn.getHeaderField("foo");
assertNull(val, "unexpected header field value: " + val);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFieldDate() throws Exception {
final var _ = this.conn.getHeaderFieldDate("bar", 42);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFieldInt() throws Exception {
final int val = this.conn.getHeaderFieldInt("hello", 42);
assertEquals(42, val, "unexpected header value");
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFieldKey() throws Exception {
final String val = this.conn.getHeaderFieldKey(42);
assertNull(val, "unexpected header value: " + val);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFieldLong() throws Exception {
final long val = this.conn.getHeaderFieldLong("foo", 42);
assertEquals(42, val, "unexpected header value");
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetHeaderFields() throws Exception {
final Map<String, List<String>> headers = this.conn.getHeaderFields();
assertNotNull(headers, "null headers");
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetInputStream() throws Exception {
try (final InputStream is = this.conn.getInputStream()) {
assertNotNull(is, "input stream is null");
}
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
@Test
void testGetOutputStream() throws Exception {
// FileURLConnection only supports reading
assertThrows(UnknownServiceException.class, this.conn::getOutputStream);
// verify that the URLConnection isn't holding on to any file descriptors
// of this test file.
Files.delete(this.testFile); // must not fail
}
}

View File

@ -0,0 +1,150 @@
/*
* Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.Collator;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*
* @test
* @summary verify the behaviour of URLConnection.getInputStream()
* for "file:" protocol
* @run junit ${test.main.class}
*/
class GetInputStreamTest {
/**
* Calls URLConnection.getInputStream() on the URLConnection for a directory and verifies
* the contents returned by the InputStream.
*/
@Test
void testDirInputStream() throws Exception {
final Path dir = Files.createTempDirectory(Path.of("."), "fileurlconn-");
final int numEntries = 3;
// write some files into that directory
for (int i = 1; i <= numEntries; i++) {
Files.writeString(dir.resolve(i + ".txt"), "" + i);
}
final String expectedDirListing = getDirListing(dir.toFile(), numEntries);
final URLConnection conn = dir.toUri().toURL().openConnection();
assertNotNull(conn, "URLConnection is null for " + dir);
// call getInputStream() and verify that the streamed directory
// listing is the expected one
try (final InputStream is = conn.getInputStream()) {
assertNotNull(is, "InputStream is null for " + conn);
final String actual = new BufferedReader(new InputStreamReader(is))
.readAllAsString();
assertEquals(expectedDirListing, actual,
"unexpected content from input stream for dir " + dir);
}
// now that we successfully obtained the InputStream, read its content
// and closed it, call getInputStream() again and verify that it can no longer
// be used to read any more content.
try (final InputStream is = conn.getInputStream()) {
assertNotNull(is, "input stream is null for " + conn);
final int readByte = is.read();
assertEquals(-1, readByte, "expected to have read EOF from the stream");
}
}
/**
* Calls URLConnection.getInputStream() on the URLConnection for a regular file and verifies
* the contents returned by the InputStream.
*/
@Test
void testRegularFileInputStream() throws Exception {
final Path dir = Files.createTempDirectory(Path.of("."), "fileurlconn-");
final Path regularFile = dir.resolve("foo.txt");
final String expectedContent = "bar";
Files.writeString(regularFile, expectedContent);
final URLConnection conn = regularFile.toUri().toURL().openConnection();
assertNotNull(conn, "URLConnection is null for " + regularFile);
// get the input stream and verify the streamed content
try (final InputStream is = conn.getInputStream()) {
assertNotNull(is, "input stream is null for " + conn);
final String actual = new BufferedReader(new InputStreamReader(is))
.readAllAsString();
assertEquals(expectedContent, actual,
"unexpected content from input stream for file " + regularFile);
}
// now that we successfully obtained the InputStream, read its content
// and closed it, call getInputStream() again and verify that it can no longer
// be used to read any more content.
try (final InputStream is = conn.getInputStream()) {
assertNotNull(is, "input stream is null for " + conn);
// for regular files the FileURLConnection's InputStream throws a IOException
// when attempting to read after EOF
final IOException thrown = assertThrows(IOException.class, is::read);
final String exMessage = thrown.getMessage();
assertEquals("Stream closed", exMessage, "unexpected exception message");
}
}
/**
* Verifies that URLConnection.getInputStream() for a non-existent file path
* throws FileNotFoundException.
*/
@Test
void testNonExistentFile() throws Exception {
final Path existentDir = Files.createTempDirectory(Path.of("."), "fileurlconn-");
final Path nonExistent = existentDir.resolve("non-existent");
final URLConnection conn = nonExistent.toUri().toURL().openConnection();
assertNotNull(conn, "URLConnection is null for " + nonExistent);
final FileNotFoundException thrown = assertThrows(FileNotFoundException.class,
conn::getInputStream);
final String exMessage = thrown.getMessage();
assertTrue(exMessage != null && exMessage.contains(nonExistent.getFileName().toString()),
"unexpected exception message: " + exMessage);
}
private static String getDirListing(final File dir, final int numExpectedEntries) {
final List<String> dirListing = Arrays.asList(dir.list());
dirListing.sort(Collator.getInstance()); // same as what FileURLConnection does
assertEquals(numExpectedEntries, dirListing.size(),
dir + " - expected " + numExpectedEntries + " entries but found: " + dirListing);
final StringBuilder sb = new StringBuilder();
for (String fileName : dirListing) {
sb.append(fileName);
sb.append("\n");
}
return sb.toString();
}
}