Skip to content

Commit 86119d3

Browse files
fix: respect read limit in BoundedInputStream.available() (#779)
* fix: correct `BoundedInputStream.getRemaining()` for unbounded streams `BoundedInputStream.getRemaining()` previously returned `0` when no read limit was set, which was misleading. It now returns `Long.MAX_VALUE` to more clearly represent an unbounded stream. The Javadoc was also updated to clarify that the method reflects only the configured limit and does **not** report the actual number of bytes available in the underlying stream. * fix: respect read limit in `BoundedInputStream.available()` `BoundedInputStream.available()` previously returned the underlying stream’s availability without applying the configured read limit. It now caps the reported value by the remaining bytes allowed by the bound. Additional changes: * Removed the `onMaxLength` call from `available()`, since `available()` does not consume data and cannot exceed the maximum count. * Reworked the `testAvailableAfterClose` and `testReadAfterClose` tests: * Old versions relied too heavily on `CharSequenceInputStream`’s specific behavior. * New parameterized tests cover all three typical cases of the underlying stream after close: no-op, return special value, or throw an exception. * Added a dedicated unit test for the corrected `available()` behavior. * fix: formatting * fix: Javadoc * fix: Tests * fix: test descriptions * fix: replace toIntExact with cast * Use consistent wording * Use consistent wording --------- Co-authored-by: Gary Gregory <[email protected]>
1 parent 51932fe commit 86119d3

File tree

3 files changed

+183
-33
lines changed

3 files changed

+183
-33
lines changed

src/changes/changes.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ The <action> type attribute can be add,update,fix,remove.
5050
<action type="fix" dev="ggregory" due-to="Gary Gregory">When testing on Java 21 and up, enable -XX:+EnableDynamicAgentLoading.</action>
5151
<action type="fix" dev="ggregory" due-to="Gary Gregory">When testing on Java 24 and up, don't fail FileUtilsListFilesTest for a different behavior in the JRE.</action>
5252
<action type="fix" dev="ggregory" due-to="Stanislav Fort, Gary Gregory">ValidatingObjectInputStream does not validate dynamic proxy interfaces.</action>
53+
<action type="fix" dev="pkarwasz" due-to="Piotr P. Karwasz">BoundedInputStream.getRemaining() now reports Long.MAX_VALUE instead of 0 when no limit is set.</action>
54+
<action type="fix" dev="pkarwasz" due-to="Piotr P. Karwasz">BoundedInputStream.available() correctly accounts for the maximum read limit.</action>
5355
<!-- ADD -->
5456
<action dev="ggregory" type="add" due-to="strangelookingnerd, Gary Gregory">FileUtils#byteCountToDisplaySize() supports Zettabyte, Yottabyte, Ronnabyte and Quettabyte #763.</action>
5557
<action dev="ggregory" type="add" due-to="strangelookingnerd, Gary Gregory">Add org.apache.commons.io.FileUtils.ONE_RB #763.</action>

src/main/java/org/apache/commons/io/input/BoundedInputStream.java

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -370,16 +370,11 @@ protected synchronized void afterRead(final int n) throws IOException {
370370
super.afterRead(n);
371371
}
372372

373-
/**
374-
* {@inheritDoc}
375-
*/
376373
@Override
377374
public int available() throws IOException {
378-
if (isMaxCount()) {
379-
onMaxLength(maxCount, getCount());
380-
return 0;
381-
}
382-
return in.available();
375+
// Safe cast: value is between 0 and Integer.MAX_VALUE
376+
final int remaining = (int) Math.min(getRemaining(), Integer.MAX_VALUE);
377+
return Math.min(super.available(), remaining);
383378
}
384379

385380
/**
@@ -405,9 +400,9 @@ public synchronized long getCount() {
405400
}
406401

407402
/**
408-
* Gets the max count of bytes to read.
403+
* Gets the maximum number of bytes to read.
409404
*
410-
* @return The max count of bytes to read.
405+
* @return The maximum number of bytes to read, or {@value IOUtils#EOF} if unbounded.
411406
* @since 2.16.0
412407
*/
413408
public long getMaxCount() {
@@ -427,13 +422,21 @@ public long getMaxLength() {
427422
}
428423

429424
/**
430-
* Gets how many bytes remain to read.
425+
* Gets the number of bytes remaining to read before the maximum is reached.
426+
*
427+
* <p>
428+
* This method does <strong>not</strong> report the bytes available in the
429+
* underlying stream; it only reflects the remaining allowance imposed by this
430+
* {@code BoundedInputStream}.
431+
* </p>
431432
*
432-
* @return bytes how many bytes remain to read.
433+
* @return The number of bytes remaining to read before the maximum is reached,
434+
* or {@link Long#MAX_VALUE} if no bound is set.
433435
* @since 2.16.0
434436
*/
435437
public long getRemaining() {
436-
return Math.max(0, getMaxCount() - getCount());
438+
final long maxCount = getMaxCount();
439+
return maxCount == EOF ? Long.MAX_VALUE : Math.max(0, maxCount - getCount());
437440
}
438441

439442
private boolean isMaxCount() {

src/test/java/org/apache/commons/io/input/BoundedInputStreamTest.java

Lines changed: 165 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,27 @@
2323
import static org.junit.jupiter.api.Assertions.assertThrows;
2424
import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
2525
import static org.junit.jupiter.api.Assertions.assertTrue;
26+
import static org.junit.jupiter.api.Assertions.fail;
27+
import static org.mockito.Mockito.mock;
28+
import static org.mockito.Mockito.times;
29+
import static org.mockito.Mockito.verify;
30+
import static org.mockito.Mockito.verifyNoMoreInteractions;
31+
import static org.mockito.Mockito.when;
2632

2733
import java.io.ByteArrayInputStream;
2834
import java.io.IOException;
2935
import java.io.InputStream;
3036
import java.nio.charset.StandardCharsets;
3137
import java.util.concurrent.atomic.AtomicBoolean;
38+
import java.util.stream.Stream;
3239

3340
import org.apache.commons.io.IOUtils;
3441
import org.apache.commons.io.test.CustomIOException;
3542
import org.apache.commons.lang3.mutable.MutableInt;
3643
import org.junit.jupiter.api.Test;
3744
import org.junit.jupiter.params.ParameterizedTest;
45+
import org.junit.jupiter.params.provider.Arguments;
46+
import org.junit.jupiter.params.provider.MethodSource;
3847
import org.junit.jupiter.params.provider.ValueSource;
3948

4049
/**
@@ -80,21 +89,74 @@ void testAfterReadConsumer() throws Exception {
8089
// @formatter:on
8190
}
8291

83-
@SuppressWarnings("resource")
84-
@Test
85-
void testAvailableAfterClose() throws Exception {
92+
static Stream<Arguments> testAvailableAfterClose() throws IOException {
93+
// Case 1: behaves like ByteArrayInputStream — close() is a no-op, available() still returns a value (e.g., 42).
94+
final InputStream noOpClose = mock(InputStream.class);
95+
when(noOpClose.available()).thenReturn(42, 42);
96+
97+
// Case 2: returns 0 after close (Commons memory-backed streams that ignore close but report 0 when exhausted).
98+
final InputStream returnsZeroAfterClose = mock(InputStream.class);
99+
when(returnsZeroAfterClose.available()).thenReturn(42, 0);
100+
101+
// Case 3: throws IOException after close (e.g., FileInputStream-like behavior).
102+
final InputStream throwsAfterClose = mock(InputStream.class);
103+
when(throwsAfterClose.available()).thenReturn(42).thenThrow(new IOException("Stream closed"));
104+
105+
return Stream.of(
106+
Arguments.of("underlying stream still returns 42 after close", noOpClose, 42),
107+
Arguments.of("underlying stream returns 0 after close", returnsZeroAfterClose, 42),
108+
Arguments.of("underlying stream throws IOException after close", throwsAfterClose, 42));
109+
}
110+
111+
@ParameterizedTest(name = "{index} — {0}")
112+
@MethodSource
113+
void testAvailableAfterClose(String caseName, InputStream delegate, int expectedBeforeClose)
114+
throws Exception {
86115
final InputStream shadow;
87-
try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) {
88-
assertTrue(in.available() > 0);
89-
shadow = in;
90-
}
91-
assertEquals(0, shadow.available());
116+
try (InputStream in = BoundedInputStream.builder()
117+
.setInputStream(delegate)
118+
.setPropagateClose(true)
119+
.get()) {
120+
// Before close: pass-through behavior
121+
assertEquals(expectedBeforeClose, in.available(), caseName + " (before close)");
122+
shadow = in; // keep reference to call after close
123+
}
124+
// Verify the underlying stream was closed
125+
verify(delegate, times(1)).close();
126+
// After close: behavior depends on the underlying stream
127+
assertEquals(0, shadow.available(), caseName + " (after close)");
128+
// Interactions: available called only once before close.
129+
verify(delegate, times(1)).available();
130+
verifyNoMoreInteractions(delegate);
92131
}
93132

94-
@Test
95-
void testAvailableAfterOpen() throws Exception {
96-
try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) {
97-
assertTrue(in.available() > 0);
133+
static Stream<Arguments> testAvailableUpperLimit() {
134+
final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
135+
return Stream.of(
136+
// Limited by maxCount
137+
Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length - 1, helloWorld.length - 1, 0),
138+
// Limited by data length
139+
Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length + 1, helloWorld.length, 0),
140+
// Limited by Integer.MAX_VALUE
141+
Arguments.of(
142+
new NullInputStream(Long.MAX_VALUE), Long.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE));
143+
}
144+
145+
@ParameterizedTest
146+
@MethodSource
147+
void testAvailableUpperLimit(InputStream input, long maxCount, int expectedBeforeSkip, int expectedAfterSkip)
148+
throws Exception {
149+
try (BoundedInputStream bounded = BoundedInputStream.builder()
150+
.setInputStream(input)
151+
.setMaxCount(maxCount)
152+
.get()) {
153+
assertEquals(
154+
expectedBeforeSkip, bounded.available(), "available should be limited by maxCount and data length");
155+
IOUtils.skip(bounded, expectedBeforeSkip);
156+
assertEquals(
157+
expectedAfterSkip,
158+
bounded.available(),
159+
"after skipping available should be limited by maxCount and data length");
98160
}
99161
}
100162

@@ -444,15 +506,58 @@ void testPublicConstructors() throws IOException {
444506
}
445507
}
446508

447-
@SuppressWarnings("resource")
448-
@Test
449-
void testReadAfterClose() throws Exception {
450-
final InputStream shadow;
451-
try (InputStream in = BoundedInputStream.builder().setCharSequence("Hi").get()) {
452-
assertTrue(in.available() > 0);
453-
shadow = in;
509+
static Stream<Arguments> testReadAfterClose() throws IOException {
510+
// Case 1: no-op close (ByteArrayInputStream-like): read() still returns a value after close
511+
final InputStream noOpClose = mock(InputStream.class);
512+
when(noOpClose.read()).thenReturn(42);
513+
514+
// Case 2: returns EOF (-1) after close
515+
final InputStream returnsEofAfterClose = mock(InputStream.class);
516+
when(returnsEofAfterClose.read()).thenReturn(IOUtils.EOF);
517+
518+
// Case 3: throws IOException after close (FileInputStream-like)
519+
final InputStream throwsAfterClose = mock(InputStream.class);
520+
final IOException closed = new IOException("Stream closed");
521+
when(throwsAfterClose.read()).thenThrow(closed);
522+
523+
return Stream.of(
524+
Arguments.of("underlying stream still reads data after close", noOpClose, 42),
525+
Arguments.of("underlying stream returns EOF after close", returnsEofAfterClose, IOUtils.EOF),
526+
Arguments.of("underlying stream throws IOException after close", throwsAfterClose, closed));
527+
}
528+
529+
@ParameterizedTest(name = "{index} — {0}")
530+
@MethodSource("testReadAfterClose")
531+
void testReadAfterClose(
532+
String caseName,
533+
InputStream delegate,
534+
Object expectedAfterClose // Integer (value) or IOException (expected thrown)
535+
) throws Exception {
536+
537+
final InputStream bounded;
538+
try (InputStream in = BoundedInputStream.builder()
539+
.setInputStream(delegate)
540+
.setPropagateClose(true)
541+
.get()) {
542+
bounded = in; // call read() only after close
543+
}
544+
545+
// Underlying stream should be closed exactly once
546+
verify(delegate, times(1)).close();
547+
548+
if (expectedAfterClose instanceof Integer) {
549+
assertEquals(expectedAfterClose, bounded.read(), caseName + " (after close)");
550+
} else if (expectedAfterClose instanceof IOException) {
551+
final IOException actual = assertThrows(IOException.class, bounded::read, caseName + " (after close)");
552+
// verify it's the exact instance we configured
553+
assertSame(expectedAfterClose, actual, caseName + " (exception instance)");
554+
} else {
555+
fail("Unexpected expectedAfterClose type: " + expectedAfterClose);
454556
}
455-
assertEquals(IOUtils.EOF, shadow.read());
557+
558+
// We only performed one read() (after close)
559+
verify(delegate, times(1)).read();
560+
verifyNoMoreInteractions(delegate);
456561
}
457562

458563
@Test
@@ -494,6 +599,46 @@ void testReadArray() throws Exception {
494599
}
495600
}
496601

602+
static Stream<Arguments> testRemaining() {
603+
return Stream.of(
604+
// Unbounded: any negative maxCount is treated as "no limit".
605+
Arguments.of("unbounded (EOF constant)", IOUtils.EOF, Long.MAX_VALUE),
606+
Arguments.of("unbounded (arbitrary negative)", Long.MIN_VALUE, Long.MAX_VALUE),
607+
608+
// Bounded: remaining equals the configured limit, regardless of underlying data size.
609+
Arguments.of("bounded (zero)", 0L, 0L),
610+
Arguments.of("bounded (small)", 1024L, 1024L),
611+
Arguments.of("bounded (Integer.MAX_VALUE)", Integer.MAX_VALUE, (long) Integer.MAX_VALUE),
612+
613+
// Bounded but extremely large: still not 'unbounded'.
614+
Arguments.of("bounded (Long.MAX_VALUE)", Long.MAX_VALUE, Long.MAX_VALUE));
615+
}
616+
617+
@ParameterizedTest(name = "{index}: {0} -> initial remaining {2}")
618+
@MethodSource
619+
void testRemaining(final String caseName, final long maxCount, final long expectedInitialRemaining)
620+
throws Exception {
621+
final byte[] data = "Hello World".getBytes(StandardCharsets.UTF_8); // 11 bytes
622+
623+
try (BoundedInputStream in = BoundedInputStream.builder()
624+
.setByteArray(data)
625+
.setMaxCount(maxCount)
626+
.get()) {
627+
// Initial remaining respects the imposed limit (or is Long.MAX_VALUE if unbounded).
628+
assertEquals(expectedInitialRemaining, in.getRemaining(), caseName + " (initial)");
629+
630+
// Skip more than the data length to exercise both bounded and unbounded paths.
631+
final long skipped = IOUtils.skip(in, 42);
632+
633+
// For unbounded streams (EOF == -1), remaining stays the same.
634+
// For bounded, it decreases by 'skipped'.
635+
final long expectedAfterSkip =
636+
in.getMaxCount() == IOUtils.EOF ? expectedInitialRemaining : expectedInitialRemaining - skipped;
637+
638+
assertEquals(expectedAfterSkip, in.getRemaining(), caseName + " (after skip)");
639+
}
640+
}
641+
497642
@Test
498643
void testReadSingle() throws Exception {
499644
final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);

0 commit comments

Comments
 (0)