View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.io.input;
18  
19  import static org.apache.commons.io.IOUtils.EOF;
20  import static org.junit.jupiter.api.Assertions.assertEquals;
21  import static org.junit.jupiter.api.Assertions.assertFalse;
22  import static org.junit.jupiter.api.Assertions.assertSame;
23  import static org.junit.jupiter.api.Assertions.assertThrows;
24  import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
25  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;
32  
33  import java.io.ByteArrayInputStream;
34  import java.io.IOException;
35  import java.io.InputStream;
36  import java.nio.charset.StandardCharsets;
37  import java.util.concurrent.atomic.AtomicBoolean;
38  import java.util.stream.Stream;
39  
40  import org.apache.commons.io.IOUtils;
41  import org.apache.commons.io.test.CustomIOException;
42  import org.apache.commons.lang3.mutable.MutableInt;
43  import org.junit.jupiter.api.Test;
44  import org.junit.jupiter.params.ParameterizedTest;
45  import org.junit.jupiter.params.provider.Arguments;
46  import org.junit.jupiter.params.provider.MethodSource;
47  import org.junit.jupiter.params.provider.ValueSource;
48  
49  /**
50   * Tests for {@link BoundedInputStream}.
51   */
52  class BoundedInputStreamTest {
53  
54      static Stream<Arguments> testAvailableAfterClose() throws IOException {
55          // Case 1: behaves like ByteArrayInputStream — close() is a no-op, available() still returns a value (e.g., 42).
56          final InputStream noOpClose = mock(InputStream.class);
57          when(noOpClose.available()).thenReturn(42, 42);
58  
59          // Case 2: returns 0 after close (Commons memory-backed streams that ignore close but report 0 when exhausted).
60          final InputStream returnsZeroAfterClose = mock(InputStream.class);
61          when(returnsZeroAfterClose.available()).thenReturn(42, 0);
62  
63          // Case 3: throws IOException after close (e.g., FileInputStream-like behavior).
64          final InputStream throwsAfterClose = mock(InputStream.class);
65          when(throwsAfterClose.available()).thenReturn(42).thenThrow(new IOException("Stream closed"));
66  
67          return Stream.of(
68                  Arguments.of("underlying stream still returns 42 after close", noOpClose, 42),
69                  Arguments.of("underlying stream returns 0 after close", returnsZeroAfterClose, 42),
70                  Arguments.of("underlying stream throws IOException after close", throwsAfterClose, 42));
71      }
72  
73      static Stream<Arguments> testAvailableUpperLimit() {
74          final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
75          return Stream.of(
76                  // Limited by maxCount
77                  Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length - 1, helloWorld.length - 1, 0),
78                  // Limited by data length
79                  Arguments.of(new ByteArrayInputStream(helloWorld), helloWorld.length + 1, helloWorld.length, 0),
80                  // Limited by Integer.MAX_VALUE
81                  Arguments.of(
82                          new NullInputStream(Long.MAX_VALUE), Long.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE));
83      }
84  
85      static Stream<Arguments> testReadAfterClose() throws IOException {
86          // Case 1: no-op close (ByteArrayInputStream-like): read() still returns a value after close
87          final InputStream noOpClose = mock(InputStream.class);
88          when(noOpClose.read()).thenReturn(42);
89  
90          // Case 2: returns EOF (-1) after close
91          final InputStream returnsEofAfterClose = mock(InputStream.class);
92          when(returnsEofAfterClose.read()).thenReturn(IOUtils.EOF);
93  
94          // Case 3: throws IOException after close (FileInputStream-like)
95          final InputStream throwsAfterClose = mock(InputStream.class);
96          final IOException closed = new IOException("Stream closed");
97          when(throwsAfterClose.read()).thenThrow(closed);
98  
99          return Stream.of(
100                 Arguments.of("underlying stream still reads data after close", noOpClose, 42),
101                 Arguments.of("underlying stream returns EOF after close", returnsEofAfterClose, IOUtils.EOF),
102                 Arguments.of("underlying stream throws IOException after close", throwsAfterClose, closed));
103     }
104 
105     static Stream<Arguments> testRemaining() {
106         return Stream.of(
107                 // Unbounded: any negative maxCount is treated as "no limit".
108                 Arguments.of("unbounded (EOF constant)", IOUtils.EOF, Long.MAX_VALUE),
109                 Arguments.of("unbounded (arbitrary negative)", Long.MIN_VALUE, Long.MAX_VALUE),
110 
111                 // Bounded: remaining equals the configured limit, regardless of underlying data size.
112                 Arguments.of("bounded (zero)", 0L, 0L),
113                 Arguments.of("bounded (small)", 1024L, 1024L),
114                 Arguments.of("bounded (Integer.MAX_VALUE)", Integer.MAX_VALUE, (long) Integer.MAX_VALUE),
115 
116                 // Bounded but extremely large: still not 'unbounded'.
117                 Arguments.of("bounded (Long.MAX_VALUE)", Long.MAX_VALUE, Long.MAX_VALUE));
118     }
119 
120     private void compare(final String message, final byte[] expected, final byte[] actual) {
121         assertEquals(expected.length, actual.length, () -> message + " (array length equals check)");
122         final MutableInt mi = new MutableInt();
123         for (int i = 0; i < expected.length; i++) {
124             mi.setValue(i);
125             assertEquals(expected[i], actual[i], () -> message + " byte[" + mi + "]");
126         }
127     }
128 
129     @Test
130     void testAfterReadConsumer() throws Exception {
131         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
132         final AtomicBoolean boolRef = new AtomicBoolean();
133         // @formatter:off
134         try (InputStream bounded = BoundedInputStream.builder()
135                 .setInputStream(new ByteArrayInputStream(hello))
136                 .setMaxCount(hello.length)
137                 .setAfterRead(i -> boolRef.set(true))
138                 .get()) {
139             IOUtils.consume(bounded);
140         }
141         // @formatter:on
142         assertTrue(boolRef.get());
143         // Throwing
144         final String message = "test exception message";
145         // @formatter:off
146         try (InputStream bounded = BoundedInputStream.builder()
147                 .setInputStream(new ByteArrayInputStream(hello))
148                 .setMaxCount(hello.length)
149                 .setAfterRead(i -> {
150                     throw new CustomIOException(message);
151                 })
152                 .get()) {
153             assertEquals(message, assertThrowsExactly(CustomIOException.class, () -> IOUtils.consume(bounded)).getMessage());
154         }
155         // @formatter:on
156     }
157 
158     @ParameterizedTest(name = "{index} — {0}")
159     @MethodSource
160     void testAvailableAfterClose(final String caseName, final InputStream delegate, final int expectedBeforeClose)
161             throws Exception {
162         final InputStream shadow;
163         try (InputStream in = BoundedInputStream.builder()
164                 .setInputStream(delegate)
165                 .setPropagateClose(true)
166                 .get()) {
167             // Before close: pass-through behavior
168             assertEquals(expectedBeforeClose, in.available(), caseName + " (before close)");
169             shadow = in; // keep reference to call after close
170         }
171         // Verify the underlying stream was closed
172         verify(delegate, times(1)).close();
173         // After close: behavior depends on the underlying stream
174         assertEquals(0, shadow.available(), caseName + " (after close)");
175         // Interactions: available called only once before close.
176         verify(delegate, times(1)).available();
177         verifyNoMoreInteractions(delegate);
178     }
179 
180     @ParameterizedTest
181     @MethodSource
182     void testAvailableUpperLimit(final InputStream input, final long maxCount, final int expectedBeforeSkip, final int expectedAfterSkip)
183             throws Exception {
184         try (BoundedInputStream bounded = BoundedInputStream.builder()
185                 .setInputStream(input)
186                 .setMaxCount(maxCount)
187                 .get()) {
188             assertEquals(
189                     expectedBeforeSkip, bounded.available(), "available should be limited by maxCount and data length");
190             IOUtils.skip(bounded, expectedBeforeSkip);
191             assertEquals(
192                     expectedAfterSkip,
193                     bounded.available(),
194                     "after skipping available should be limited by maxCount and data length");
195         }
196     }
197 
198     @Test
199     void testBuilderGet() {
200         // java.lang.IllegalStateException: origin == null
201         assertThrows(IllegalStateException.class, () -> BoundedInputStream.builder().get());
202     }
203 
204     @Test
205     void testCloseHandleIOException() throws IOException {
206         ProxyInputStreamTest.testCloseHandleIOException(BoundedInputStream.builder());
207     }
208 
209     @ParameterizedTest
210     @ValueSource(longs = { -100, -1, 0, 1, 2, 4, 8, 16, 32, 64 })
211     void testCounts(final long startCount) throws Exception {
212         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
213         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
214         final long actualStart = startCount < 0 ? 0 : startCount;
215         // limit = length
216         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setCount(startCount)
217                 .setMaxCount(helloWorld.length).get()) {
218             assertTrue(bounded.markSupported());
219             assertEquals(helloWorld.length, bounded.getMaxCount());
220             assertEquals(helloWorld.length, bounded.getMaxLength());
221             assertEquals(actualStart, bounded.getCount());
222             assertEquals(Math.max(0, bounded.getMaxCount() - actualStart), bounded.getRemaining());
223             assertEquals(Math.max(0, bounded.getMaxLength() - actualStart), bounded.getRemaining());
224             int readCount = 0;
225             for (int i = 0; i < helloWorld.length; i++) {
226                 final byte expectedCh = bounded.getRemaining() > 0 ? helloWorld[i] : EOF;
227                 final int actualCh = bounded.read();
228                 assertEquals(expectedCh, actualCh, "limit = length byte[" + i + "]");
229                 if (actualCh != EOF) {
230                     readCount++;
231                 }
232                 assertEquals(helloWorld.length, bounded.getMaxCount());
233                 assertEquals(helloWorld.length, bounded.getMaxLength());
234                 assertEquals(actualStart + readCount, bounded.getCount(), "i=" + i);
235                 assertEquals(Math.max(0, bounded.getMaxCount() - (readCount + actualStart)), bounded.getRemaining());
236                 assertEquals(Math.max(0, bounded.getMaxLength() - (readCount + actualStart)), bounded.getRemaining());
237             }
238             assertEquals(-1, bounded.read(), "limit = length end");
239             assertEquals(helloWorld.length, bounded.getMaxLength());
240             assertEquals(readCount + actualStart, bounded.getCount());
241             assertEquals(0, bounded.getRemaining());
242             assertEquals(0, bounded.available());
243             // should be invariant
244             assertTrue(bounded.markSupported());
245         }
246         // limit > length
247         final int maxCountP1 = helloWorld.length + 1;
248         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setCount(startCount)
249                 .setMaxCount(maxCountP1).get()) {
250             assertTrue(bounded.markSupported());
251             assertEquals(maxCountP1, bounded.getMaxLength());
252             assertEquals(actualStart, bounded.getCount());
253             assertEquals(Math.max(0, bounded.getMaxCount() - actualStart), bounded.getRemaining());
254             assertEquals(Math.max(0, bounded.getMaxLength() - actualStart), bounded.getRemaining());
255             int readCount = 0;
256             for (int i = 0; i < helloWorld.length; i++) {
257                 final byte expectedCh = bounded.getRemaining() > 0 ? helloWorld[i] : EOF;
258                 final int actualCh = bounded.read();
259                 assertEquals(expectedCh, actualCh, "limit = length byte[" + i + "]");
260                 if (actualCh != EOF) {
261                     readCount++;
262                 }
263                 assertEquals(maxCountP1, bounded.getMaxCount());
264                 assertEquals(maxCountP1, bounded.getMaxLength());
265                 assertEquals(actualStart + readCount, bounded.getCount(), "i=" + i);
266                 assertEquals(Math.max(0, bounded.getMaxCount() - (readCount + actualStart)), bounded.getRemaining());
267                 assertEquals(Math.max(0, bounded.getMaxLength() - (readCount + actualStart)), bounded.getRemaining());
268             }
269             assertEquals(-1, bounded.read(), "limit > length end");
270             assertEquals(0, bounded.available());
271             assertEquals(maxCountP1, bounded.getMaxLength());
272             assertEquals(readCount + actualStart, bounded.getCount());
273             assertEquals(Math.max(0, maxCountP1 - bounded.getCount()), bounded.getRemaining());
274             // should be invariant
275             assertTrue(bounded.markSupported());
276         }
277         // limit < length
278         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(hello.length).get()) {
279             assertTrue(bounded.markSupported());
280             assertEquals(hello.length, bounded.getMaxLength());
281             assertEquals(0, bounded.getCount());
282             assertEquals(bounded.getMaxLength(), bounded.getRemaining());
283             int readCount = 0;
284             for (int i = 0; i < hello.length; i++) {
285                 assertEquals(hello[i], bounded.read(), "limit < length byte[" + i + "]");
286                 readCount++;
287                 assertEquals(hello.length, bounded.getMaxLength());
288                 assertEquals(readCount, bounded.getCount());
289                 assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
290             }
291             assertEquals(-1, bounded.read(), "limit < length end");
292             assertEquals(0, bounded.available());
293             assertEquals(hello.length, bounded.getMaxLength());
294             assertEquals(readCount, bounded.getCount());
295             assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
296             // should be invariant
297             assertTrue(bounded.markSupported());
298         }
299     }
300 
301     @Test
302     void testMarkReset() throws Exception {
303         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
304         final int helloWorldLen = helloWorld.length;
305         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
306         final byte[] world = " World".getBytes(StandardCharsets.UTF_8);
307         final int helloLen = hello.length;
308         // limit = -1
309         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).get()) {
310             assertTrue(bounded.markSupported());
311             bounded.mark(0);
312             compare("limit = -1", helloWorld, IOUtils.toByteArray(bounded));
313             // should be invariant
314             assertTrue(bounded.markSupported());
315             // again
316             bounded.reset();
317             compare("limit = -1", hello, IOUtils.toByteArray(bounded, helloLen));
318             bounded.mark(helloWorldLen);
319             compare("limit = -1", world, IOUtils.toByteArray(bounded));
320             bounded.reset();
321             compare("limit = -1", world, IOUtils.toByteArray(bounded));
322             // should be invariant
323             assertTrue(bounded.markSupported());
324         }
325         // limit = 0
326         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(0).get()) {
327             assertTrue(bounded.markSupported());
328             bounded.mark(0);
329             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
330             // should be invariant
331             assertTrue(bounded.markSupported());
332             // again
333             bounded.reset();
334             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
335             bounded.mark(helloWorldLen);
336             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
337             // should be invariant
338             assertTrue(bounded.markSupported());
339         }
340         // limit = length
341         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
342                 .setMaxCount(helloWorld.length).get()) {
343             assertTrue(bounded.markSupported());
344             bounded.mark(0);
345             compare("limit = length", helloWorld, IOUtils.toByteArray(bounded));
346             // should be invariant
347             assertTrue(bounded.markSupported());
348             // again
349             bounded.reset();
350             compare("limit = length", hello, IOUtils.toByteArray(bounded, helloLen));
351             bounded.mark(helloWorldLen);
352             compare("limit = length", world, IOUtils.toByteArray(bounded));
353             bounded.reset();
354             compare("limit = length", world, IOUtils.toByteArray(bounded));
355             // should be invariant
356             assertTrue(bounded.markSupported());
357         }
358         // limit > length
359         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
360                 .setMaxCount(helloWorld.length + 1).get()) {
361             assertTrue(bounded.markSupported());
362             bounded.mark(0);
363             compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
364             // should be invariant
365             assertTrue(bounded.markSupported());
366             // again
367             bounded.reset();
368             compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
369             bounded.reset();
370             compare("limit > length", hello, IOUtils.toByteArray(bounded, helloLen));
371             bounded.mark(helloWorldLen);
372             compare("limit > length", world, IOUtils.toByteArray(bounded));
373             bounded.reset();
374             compare("limit > length", world, IOUtils.toByteArray(bounded));
375             // should be invariant
376             assertTrue(bounded.markSupported());
377         }
378         // limit < length
379         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
380                 .setMaxCount(helloWorld.length - (hello.length + 1)).get()) {
381             assertTrue(bounded.markSupported());
382             bounded.mark(0);
383             compare("limit < length", hello, IOUtils.toByteArray(bounded));
384             // should be invariant
385             assertTrue(bounded.markSupported());
386             // again
387             bounded.reset();
388             compare("limit < length", hello, IOUtils.toByteArray(bounded));
389             bounded.reset();
390             compare("limit < length", hello, IOUtils.toByteArray(bounded, helloLen));
391             bounded.mark(helloWorldLen);
392             compare("limit < length", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
393             bounded.reset();
394             compare("limit < length", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
395             // should be invariant
396             assertTrue(bounded.markSupported());
397         }
398     }
399 
400     @Test
401     void testOnMaxCountConsumer() throws Exception {
402         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
403         final AtomicBoolean boolRef = new AtomicBoolean();
404         // @formatter:off
405         try (BoundedInputStream bounded = BoundedInputStream.builder()
406                 .setInputStream(new ByteArrayInputStream(hello))
407                 .setMaxCount(hello.length)
408                 .setOnMaxCount(null) // should not blow up
409                 .setOnMaxCount((m, c) -> boolRef.set(true))
410                 .get()) {
411             IOUtils.consume(bounded);
412         }
413         // @formatter:on
414         assertTrue(boolRef.get());
415         // Throwing
416         final String message = "test exception message";
417         // @formatter:off
418         try (BoundedInputStream bounded = BoundedInputStream.builder()
419                 .setInputStream(new ByteArrayInputStream(hello))
420                 .setMaxCount(hello.length)
421                 .setOnMaxCount((m, c) -> {
422                     throw new CustomIOException(message);
423                 })
424                 .get()) {
425             assertEquals(message, assertThrowsExactly(CustomIOException.class, () -> IOUtils.consume(bounded)).getMessage());
426         }
427         // @formatter:on
428     }
429 
430     @SuppressWarnings("deprecation")
431     @Test
432     void testOnMaxLength() throws Exception {
433         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
434         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
435         final AtomicBoolean boolRef = new AtomicBoolean();
436         // limit = length
437         try (BoundedInputStream bounded = BoundedInputStream.builder()
438                 .setInputStream(new ByteArrayInputStream(helloWorld))
439                 .setMaxCount(helloWorld.length)
440                 .setOnMaxCount((m, c) -> boolRef.set(true))
441                 .get()) {
442             assertTrue(bounded.markSupported());
443             assertEquals(helloWorld.length, bounded.getMaxCount());
444             assertEquals(helloWorld.length, bounded.getMaxLength());
445             assertEquals(0, bounded.getCount());
446             assertEquals(bounded.getMaxCount(), bounded.getRemaining());
447             assertEquals(bounded.getMaxLength(), bounded.getRemaining());
448             assertFalse(boolRef.get());
449             int readCount = 0;
450             for (int i = 0; i < helloWorld.length; i++) {
451                 assertEquals(helloWorld[i], bounded.read(), "limit = length byte[" + i + "]");
452                 readCount++;
453                 assertEquals(helloWorld.length, bounded.getMaxCount());
454                 assertEquals(helloWorld.length, bounded.getMaxLength());
455                 assertEquals(readCount, bounded.getCount());
456                 assertEquals(bounded.getMaxCount() - readCount, bounded.getRemaining());
457                 assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
458             }
459             assertEquals(-1, bounded.read(), "limit = length end");
460             assertEquals(0, bounded.available());
461             assertEquals(helloWorld.length, bounded.getMaxLength());
462             assertEquals(readCount, bounded.getCount());
463             assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
464             assertTrue(boolRef.get());
465             // should be invariant
466             assertTrue(bounded.markSupported());
467         }
468         // limit > length
469         boolRef.set(false);
470         final int length2 = helloWorld.length + 1;
471         try (BoundedInputStream bounded = BoundedInputStream.builder()
472                 .setInputStream(new ByteArrayInputStream(helloWorld))
473                 .setMaxCount(length2)
474                 .setOnMaxCount((m, c) -> boolRef.set(true))
475                 .get()) {
476             assertTrue(bounded.markSupported());
477             assertEquals(length2, bounded.getMaxLength());
478             assertEquals(0, bounded.getCount());
479             assertEquals(bounded.getMaxLength(), bounded.getRemaining());
480             assertFalse(boolRef.get());
481             int readCount = 0;
482             for (int i = 0; i < helloWorld.length; i++) {
483                 assertEquals(helloWorld[i], bounded.read(), "limit > length byte[" + i + "]");
484                 readCount++;
485                 assertEquals(length2, bounded.getMaxLength());
486                 assertEquals(readCount, bounded.getCount());
487                 assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
488             }
489             assertEquals(0, bounded.available());
490             assertEquals(-1, bounded.read(), "limit > length end");
491             assertEquals(length2, bounded.getMaxLength());
492             assertEquals(readCount, bounded.getCount());
493             assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
494             assertFalse(boolRef.get());
495             // should be invariant
496             assertTrue(bounded.markSupported());
497         }
498         // limit < length
499         boolRef.set(false);
500         try (BoundedInputStream bounded = BoundedInputStream.builder()
501                 .setInputStream(new ByteArrayInputStream(helloWorld))
502                 .setMaxCount(hello.length)
503                 .setOnMaxCount((m, c) -> boolRef.set(true))
504                 .get()) {
505             assertTrue(bounded.markSupported());
506             assertEquals(hello.length, bounded.getMaxLength());
507             assertEquals(0, bounded.getCount());
508             assertEquals(bounded.getMaxLength(), bounded.getRemaining());
509             assertFalse(boolRef.get());
510             int readCount = 0;
511             for (int i = 0; i < hello.length; i++) {
512                 assertEquals(hello[i], bounded.read(), "limit < length byte[" + i + "]");
513                 readCount++;
514                 assertEquals(hello.length, bounded.getMaxLength());
515                 assertEquals(readCount, bounded.getCount());
516                 assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
517             }
518             assertEquals(-1, bounded.read(), "limit < length end");
519             assertEquals(hello.length, bounded.getMaxLength());
520             assertEquals(readCount, bounded.getCount());
521             assertEquals(bounded.getMaxLength() - readCount, bounded.getRemaining());
522             assertTrue(boolRef.get());
523             // should be invariant
524             assertTrue(bounded.markSupported());
525         }
526     }
527 
528     @SuppressWarnings("deprecation")
529     @Test
530     void testPublicConstructors() throws IOException {
531         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
532         try (ByteArrayInputStream baos = new ByteArrayInputStream(helloWorld);
533                 BoundedInputStream inputStream = new BoundedInputStream(baos)) {
534             assertSame(baos, inputStream.unwrap());
535         }
536         final long maxCount = 2;
537         try (ByteArrayInputStream baos = new ByteArrayInputStream(helloWorld);
538                 BoundedInputStream inputStream = new BoundedInputStream(baos, maxCount)) {
539             assertSame(baos, inputStream.unwrap());
540             assertSame(maxCount, inputStream.getMaxCount());
541         }
542     }
543 
544     @ParameterizedTest(name = "{index} — {0}")
545     @MethodSource("testReadAfterClose")
546     void testReadAfterClose(
547             final String caseName,
548             final InputStream delegate,
549             final Object expectedAfterClose // Integer (value) or IOException (expected thrown)
550             ) throws Exception {
551 
552         final InputStream bounded;
553         try (InputStream in = BoundedInputStream.builder()
554                 .setInputStream(delegate)
555                 .setPropagateClose(true)
556                 .get()) {
557             bounded = in; // call read() only after close
558         }
559 
560         // Underlying stream should be closed exactly once
561         verify(delegate, times(1)).close();
562 
563         if (expectedAfterClose instanceof Integer) {
564             assertEquals(expectedAfterClose, bounded.read(), caseName + " (after close)");
565         } else if (expectedAfterClose instanceof IOException) {
566             final IOException actual = assertThrows(IOException.class, bounded::read, caseName + " (after close)");
567             // verify it's the exact instance we configured
568             assertSame(expectedAfterClose, actual, caseName + " (exception instance)");
569         } else {
570             fail("Unexpected expectedAfterClose type: " + expectedAfterClose);
571         }
572 
573         // We only performed one read() (after close)
574         verify(delegate, times(1)).read();
575         verifyNoMoreInteractions(delegate);
576     }
577 
578     @Test
579     void testReadArray() throws Exception {
580         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
581         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
582         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).get()) {
583             assertTrue(bounded.markSupported());
584             compare("limit = -1", helloWorld, IOUtils.toByteArray(bounded));
585             // should be invariant
586             assertTrue(bounded.markSupported());
587         }
588         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(0).get()) {
589             assertTrue(bounded.markSupported());
590             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
591             // should be invariant
592             assertTrue(bounded.markSupported());
593         }
594         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
595                 .setMaxCount(helloWorld.length).get()) {
596             assertTrue(bounded.markSupported());
597             compare("limit = length", helloWorld, IOUtils.toByteArray(bounded));
598             // should be invariant
599             assertTrue(bounded.markSupported());
600         }
601         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
602                 .setMaxCount(helloWorld.length + 1).get()) {
603             assertTrue(bounded.markSupported());
604             compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
605             // should be invariant
606             assertTrue(bounded.markSupported());
607         }
608         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
609                 .setMaxCount(helloWorld.length - 6).get()) {
610             assertTrue(bounded.markSupported());
611             compare("limit < length", hello, IOUtils.toByteArray(bounded));
612             // should be invariant
613             assertTrue(bounded.markSupported());
614         }
615     }
616 
617     @Test
618     void testReadSingle() throws Exception {
619         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
620         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
621         // limit = length
622         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(helloWorld.length)
623                 .get()) {
624             assertTrue(bounded.markSupported());
625             for (int i = 0; i < helloWorld.length; i++) {
626                 assertEquals(helloWorld[i], bounded.read(), "limit = length byte[" + i + "]");
627             }
628             assertEquals(-1, bounded.read(), "limit = length end");
629             // should be invariant
630             assertTrue(bounded.markSupported());
631         }
632         // limit > length
633         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(helloWorld.length + 1)
634                 .get()) {
635             assertTrue(bounded.markSupported());
636             for (int i = 0; i < helloWorld.length; i++) {
637                 assertEquals(helloWorld[i], bounded.read(), "limit > length byte[" + i + "]");
638             }
639             assertEquals(-1, bounded.read(), "limit > length end");
640             // should be invariant
641             assertTrue(bounded.markSupported());
642         }
643         // limit < length
644         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(hello.length).get()) {
645             assertTrue(bounded.markSupported());
646             for (int i = 0; i < hello.length; i++) {
647                 assertEquals(hello[i], bounded.read(), "limit < length byte[" + i + "]");
648             }
649             assertEquals(-1, bounded.read(), "limit < length end");
650             // should be invariant
651             assertTrue(bounded.markSupported());
652         }
653     }
654 
655     @ParameterizedTest(name = "{index}: {0} -> initial remaining {2}")
656     @MethodSource
657     void testRemaining(final String caseName, final long maxCount, final long expectedInitialRemaining)
658             throws Exception {
659         final byte[] data = "Hello World".getBytes(StandardCharsets.UTF_8); // 11 bytes
660 
661         try (BoundedInputStream in = BoundedInputStream.builder()
662                 .setByteArray(data)
663                 .setMaxCount(maxCount)
664                 .get()) {
665             // Initial remaining respects the imposed limit (or is Long.MAX_VALUE if unbounded).
666             assertEquals(expectedInitialRemaining, in.getRemaining(), caseName + " (initial)");
667 
668             // Skip more than the data length to exercise both bounded and unbounded paths.
669             final long skipped = IOUtils.skip(in, 42);
670 
671             // For unbounded streams (EOF == -1), remaining stays the same.
672             // For bounded, it decreases by 'skipped'.
673             final long expectedAfterSkip =
674                     in.getMaxCount() == IOUtils.EOF ? expectedInitialRemaining : expectedInitialRemaining - skipped;
675 
676             assertEquals(expectedAfterSkip, in.getRemaining(), caseName + " (after skip)");
677         }
678     }
679 
680     @Test
681     void testReset() throws Exception {
682         final byte[] helloWorld = "Hello World".getBytes(StandardCharsets.UTF_8);
683         final byte[] hello = "Hello".getBytes(StandardCharsets.UTF_8);
684         // limit = -1
685         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).get()) {
686             assertTrue(bounded.markSupported());
687             bounded.reset();
688             compare("limit = -1", helloWorld, IOUtils.toByteArray(bounded));
689             // should be invariant
690             assertTrue(bounded.markSupported());
691             // again
692             bounded.reset();
693             compare("limit = -1", helloWorld, IOUtils.toByteArray(bounded));
694             // should be invariant
695             assertTrue(bounded.markSupported());
696         }
697         // limit = 0
698         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld)).setMaxCount(0).get()) {
699             assertTrue(bounded.markSupported());
700             bounded.reset();
701             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
702             // should be invariant
703             assertTrue(bounded.markSupported());
704             // again
705             bounded.reset();
706             compare("limit = 0", IOUtils.EMPTY_BYTE_ARRAY, IOUtils.toByteArray(bounded));
707             // should be invariant
708             assertTrue(bounded.markSupported());
709         }
710         // limit = length
711         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
712                 .setMaxCount(helloWorld.length).get()) {
713             assertTrue(bounded.markSupported());
714             bounded.reset();
715             compare("limit = length", helloWorld, IOUtils.toByteArray(bounded));
716             // should be invariant
717             assertTrue(bounded.markSupported());
718             // again
719             bounded.reset();
720             compare("limit = length", helloWorld, IOUtils.toByteArray(bounded));
721             // should be invariant
722             assertTrue(bounded.markSupported());
723         }
724         // limit > length
725         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
726                 .setMaxCount(helloWorld.length + 1).get()) {
727             assertTrue(bounded.markSupported());
728             bounded.reset();
729             compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
730             // should be invariant
731             assertTrue(bounded.markSupported());
732             // again
733             bounded.reset();
734             compare("limit > length", helloWorld, IOUtils.toByteArray(bounded));
735             // should be invariant
736             assertTrue(bounded.markSupported());
737         }
738         // limit < length
739         try (BoundedInputStream bounded = BoundedInputStream.builder().setInputStream(new ByteArrayInputStream(helloWorld))
740                 .setMaxCount(helloWorld.length - 6).get()) {
741             assertTrue(bounded.markSupported());
742             bounded.reset();
743             compare("limit < length", hello, IOUtils.toByteArray(bounded));
744             // should be invariant
745             assertTrue(bounded.markSupported());
746             // again
747             bounded.reset();
748             compare("limit < length", hello, IOUtils.toByteArray(bounded));
749             // should be invariant
750             assertTrue(bounded.markSupported());
751         }
752     }
753 }