From dd826500ae0767daf31ecb7adc03eedcf24edf20 Mon Sep 17 00:00:00 2001 From: Rune Flobakk Date: Tue, 30 Jul 2024 12:50:20 +0200 Subject: [PATCH 1/4] Refactor to single test with nested variants --- ...nTest.java => LimitedInputStreamTest.java} | 98 ++++++++++++++----- .../io/LimitedInputStreamYieldingEofTest.java | 70 ------------- 2 files changed, 76 insertions(+), 92 deletions(-) rename src/test/java/no/digipost/io/{LimitedInputStreamThrowingExceptionTest.java => LimitedInputStreamTest.java} (50%) delete mode 100644 src/test/java/no/digipost/io/LimitedInputStreamYieldingEofTest.java diff --git a/src/test/java/no/digipost/io/LimitedInputStreamThrowingExceptionTest.java b/src/test/java/no/digipost/io/LimitedInputStreamTest.java similarity index 50% rename from src/test/java/no/digipost/io/LimitedInputStreamThrowingExceptionTest.java rename to src/test/java/no/digipost/io/LimitedInputStreamTest.java index f8e03bf..a22b9b5 100644 --- a/src/test/java/no/digipost/io/LimitedInputStreamThrowingExceptionTest.java +++ b/src/test/java/no/digipost/io/LimitedInputStreamTest.java @@ -15,8 +15,9 @@ */ package no.digipost.io; -import static org.apache.commons.io.IOUtils.toByteArray; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.quicktheories.core.Gen; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -24,47 +25,99 @@ import java.nio.ByteBuffer; import java.security.DigestException; import java.security.GeneralSecurityException; +import java.util.Arrays; import java.util.function.Supplier; -import static uk.co.probablyfine.matchers.Java8Matchers.where; import static java.nio.charset.StandardCharsets.UTF_8; import static no.digipost.DiggIO.limit; import static no.digipost.io.DataSize.bytes; +import static org.apache.commons.io.IOUtils.toByteArray; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.either; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.quicktheories.QuickTheory.qt; +import static org.quicktheories.generators.SourceDSL.integers; +import static org.quicktheories.generators.SourceDSL.strings; +import static uk.co.probablyfine.matchers.Java8Matchers.where; -public class LimitedInputStreamThrowingExceptionTest { +class LimitedInputStreamTest { - @Test - public void readsAnInputStreamToTheEnd() throws Exception { - assertThat(testLimitedStream("xyz", null), is("xyz")); - } + @Nested + class YieldingEofWhenReachingLimit { - @Test - public void throwsIOExceptionIfTooManyBytes() throws Exception { - IOException tooManyBytes = new IOException(); - assertThat(assertThrows(IOException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), sameInstance(tooManyBytes)); - } + @Test + void readsJustTheBytesWithoutReachingTheLimit() throws IOException { + String data = "Hello World!"; + DataSize size = DataSize.bytes(data.getBytes().length); + try (InputStream in1 = limit(new ByteArrayInputStream(data.getBytes()), size); + InputStream in2 = limit(new ByteArrayInputStream(data.getBytes()), size)) { - @Test - public void throwsRuntimeExceptionIfTooManyBytes() throws Exception { - RuntimeException tooManyBytes = new IllegalStateException(); - assertThat(assertThrows(RuntimeException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), sameInstance(tooManyBytes)); + byte[] readBytes = toByteArray(in1); + byte[] fromSingleByteReads = toByteArrayUsingSingleByteReads(in2, size); + assertArrayEquals(data.getBytes(), readBytes); + assertArrayEquals(readBytes, fromSingleByteReads); + } + } + + + @Test + void neverReadsMoreThanTheSetLimit() { + Gen byteData = strings().allPossible().ofLengthBetween(0, 256).map(s -> s.getBytes()); + Gen limits = integers().between(0, 16).map(DataSize::bytes); + + qt() + .forAll(byteData, limits) + .check((data, limit) -> { + try (InputStream in1 = limit(new ByteArrayInputStream(data), limit); + InputStream in2 = limit(new ByteArrayInputStream(data), limit)) { + byte[] readBytes = toByteArray(in1); + byte[] fromSingleByteReads = toByteArrayUsingSingleByteReads(in2, DataSize.bytes(readBytes.length)); + return readBytes.length <= limit.toBytes() && + Arrays.equals(readBytes, fromSingleByteReads); + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } + }); + } } - @Test - public void wrapsOtherCheckedExceptionsThanIOExceptionAsRuntimeException() throws Exception { - GeneralSecurityException tooManyBytes = new DigestException(); - assertThat(assertThrows(RuntimeException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), where(Exception::getCause, sameInstance(tooManyBytes))); + + + @Nested + class ThrowingExceptionWhenReachingLimit { + + @Test + public void readsAnInputStreamToTheEnd() throws Exception { + assertThat(testLimitedStream("xyz", null), is("xyz")); + } + + @Test + public void throwsIOExceptionIfTooManyBytes() throws Exception { + IOException tooManyBytes = new IOException(); + assertThat(assertThrows(IOException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), sameInstance(tooManyBytes)); + } + + @Test + public void throwsRuntimeExceptionIfTooManyBytes() throws Exception { + RuntimeException tooManyBytes = new IllegalStateException(); + assertThat(assertThrows(RuntimeException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), sameInstance(tooManyBytes)); + } + + @Test + public void wrapsOtherCheckedExceptionsThanIOExceptionAsRuntimeException() throws Exception { + GeneralSecurityException tooManyBytes = new DigestException(); + assertThat(assertThrows(RuntimeException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), where(Exception::getCause, sameInstance(tooManyBytes))); + } + } - private String testLimitedStream(String content, Supplier throwIfTooManyBytes) throws Exception { + private static String testLimitedStream(String content, Supplier throwIfTooManyBytes) throws Exception { final byte[] contentBytes = content.getBytes(UTF_8); DataSize contentSize = DataSize.bytes(contentBytes.length); final InputStream limitedInputStream1; @@ -105,7 +158,7 @@ private String testLimitedStream(String content, Supplier t } - static byte[] toByteArrayUsingSingleByteReads(InputStream toRead, DataSize amountToRead) throws IOException { + private static byte[] toByteArrayUsingSingleByteReads(InputStream toRead, DataSize amountToRead) throws IOException { int next; ByteBuffer byteBuffer = ByteBuffer.allocate((int) amountToRead.toBytes()); while ((next = toRead.read()) != -1) { @@ -117,4 +170,5 @@ static byte[] toByteArrayUsingSingleByteReads(InputStream toRead, DataSize amoun byte[] readBytes = byteBuffer.array(); return readBytes; } + } diff --git a/src/test/java/no/digipost/io/LimitedInputStreamYieldingEofTest.java b/src/test/java/no/digipost/io/LimitedInputStreamYieldingEofTest.java deleted file mode 100644 index 90662c6..0000000 --- a/src/test/java/no/digipost/io/LimitedInputStreamYieldingEofTest.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) Posten Norge AS - * - * Licensed 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 no.digipost.io; - -import static org.apache.commons.io.IOUtils.toByteArray; -import org.junit.jupiter.api.Test; -import org.quicktheories.core.Gen; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.Arrays; - -import static no.digipost.DiggIO.limit; -import static no.digipost.io.LimitedInputStreamThrowingExceptionTest.toByteArrayUsingSingleByteReads; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.quicktheories.QuickTheory.qt; -import static org.quicktheories.generators.SourceDSL.integers; -import static org.quicktheories.generators.SourceDSL.strings; - -public class LimitedInputStreamYieldingEofTest { - - @Test - public void readsJustTheBytesWithoutReachingTheLimit() throws IOException { - String data = "Hello World!"; - DataSize size = DataSize.bytes(data.getBytes().length); - try (InputStream in1 = limit(new ByteArrayInputStream(data.getBytes()), size); - InputStream in2 = limit(new ByteArrayInputStream(data.getBytes()), size)) { - - byte[] readBytes = toByteArray(in1); - byte[] fromSingleByteReads = toByteArrayUsingSingleByteReads(in2, size); - assertArrayEquals(data.getBytes(), readBytes); - assertArrayEquals(readBytes, fromSingleByteReads); - } - } - - - @Test - public void neverReadsMoreThanTheSetLimit() { - Gen byteData = strings().allPossible().ofLengthBetween(0, 256).map(s -> s.getBytes()); - Gen limits = integers().between(0, 16).map(DataSize::bytes); - - qt() - .forAll(byteData, limits) - .check((data, limit) -> { - try (InputStream in1 = limit(new ByteArrayInputStream(data), limit); - InputStream in2 = limit(new ByteArrayInputStream(data), limit)) { - byte[] readBytes = toByteArray(in1); - byte[] fromSingleByteReads = toByteArrayUsingSingleByteReads(in2, DataSize.bytes(readBytes.length)); - return readBytes.length <= limit.toBytes() && - Arrays.equals(readBytes, fromSingleByteReads); - } catch (IOException e) { - throw new RuntimeException(e.getMessage(), e); - } - }); - } -} From 67e2f785bdd19c6255d160bf430bde4948b3080a Mon Sep 17 00:00:00 2001 From: Rune Flobakk Date: Tue, 30 Jul 2024 12:54:15 +0200 Subject: [PATCH 2/4] Use long instead of DataSize in LimitedInputStream It is implicit that we are dealing with bytes in the context of an InputStream, and we were invoking .toBytes() on every use anyway. --- src/main/java/no/digipost/DiggIO.java | 4 ++-- .../java/no/digipost/io/LimitedInputStream.java | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/no/digipost/DiggIO.java b/src/main/java/no/digipost/DiggIO.java index b83a5ee..465d2e8 100644 --- a/src/main/java/no/digipost/DiggIO.java +++ b/src/main/java/no/digipost/DiggIO.java @@ -80,7 +80,7 @@ public static Function autoClosing(ThrowingFu * @see #limit(InputStream, DataSize, Supplier) */ public static InputStream limit(InputStream inputStream, DataSize maxDataToRead) { - return new LimitedInputStream(inputStream, maxDataToRead, LimitedInputStream.SILENTLY_EOF_ON_REACHING_LIMIT); + return new LimitedInputStream(inputStream, maxDataToRead.toBytes(), LimitedInputStream.SILENTLY_EOF_ON_REACHING_LIMIT); } @@ -94,7 +94,7 @@ public static InputStream limit(InputStream inputStream, DataSize maxDataToRead) * a non-{@link RuntimeException} which is not an {@link IOException}, it will be wrapped in a {@code RuntimeException}. */ public static InputStream limit(InputStream inputStream, DataSize maxDataToRead, Supplier throwIfTooManyBytes) { - return new LimitedInputStream(inputStream, maxDataToRead, throwIfTooManyBytes); + return new LimitedInputStream(inputStream, maxDataToRead.toBytes(), throwIfTooManyBytes); } diff --git a/src/main/java/no/digipost/io/LimitedInputStream.java b/src/main/java/no/digipost/io/LimitedInputStream.java index 79d4503..2a71709 100644 --- a/src/main/java/no/digipost/io/LimitedInputStream.java +++ b/src/main/java/no/digipost/io/LimitedInputStream.java @@ -53,17 +53,28 @@ private SilentlyEofWhenReachingLimit() {} public static final Supplier SILENTLY_EOF_ON_REACHING_LIMIT = new SilentlyEofWhenReachingLimit(); - private final DataSize limit; + private final long maxBytesCount; private final Supplier throwIfTooManyBytes; private long count; /** * @see no.digipost.DiggIO#limit(InputStream, DataSize, Supplier) + * + * @deprecated Pending removal to avoid depending on {@link DataSize}. The constructor methods provided in + * {@link no.digipost.DiggIO} will allow using {@code DataSize}. */ + @Deprecated public LimitedInputStream(InputStream inputStream, DataSize maxDataToRead, Supplier throwIfTooManyBytes) { + this(inputStream, maxDataToRead.toBytes(), throwIfTooManyBytes); + } + + /** + * @see no.digipost.DiggIO#limit(InputStream, DataSize, Supplier) + */ + public LimitedInputStream(InputStream inputStream, long maxBytesCount, Supplier throwIfTooManyBytes) { super(inputStream); - this.limit = maxDataToRead; + this.maxBytesCount = maxBytesCount; this.throwIfTooManyBytes = throwIfTooManyBytes; } @@ -134,7 +145,7 @@ public int read(byte[] b, int off, int len) throws IOException { private boolean hasReachedLimit() throws IOException { - if (count > limit.toBytes()) { + if (count > maxBytesCount) { if (throwIfTooManyBytes == SILENTLY_EOF_ON_REACHING_LIMIT) { return true; } From ecb3a8eafa89b90d59e98c1e238957df413697b3 Mon Sep 17 00:00:00 2001 From: Rune Flobakk Date: Wed, 31 Jul 2024 11:40:15 +0200 Subject: [PATCH 3/4] Rewind BufferedInputStream when reaching limit If using a LimitedInputStream for reading a certain amount of "header" data from an InputStream, then rewinding back to start in order to process the entire stream. E.g. to persist it somewhere. The problem with this approach is that a LimitedInputStream can be instructed to throw an exception if trying to read past the limit (i.e. it is expected to finish processing fairly early in the stream, and then end reading, and the limit is to protect spooling through a potentially huge amount of data), and in the event of actually reaching the limit, a LimitedInputStream _must_ read at least one more byte in order to determine if the underlying stream is exhausted and yields -1, or if it has more data, and followingly the LimitedInputStream must throw an exception. This reeks of a design error in LimitedInputStream. Perhaps having the variance of throwing an exception on reaching the "end" of a limited stream is flawed, and this issue demonstrates that. A LimitedInputStream introduces a potential earlier EOF, and it should perhaps strictly treat it like that and yield -1 in any case it reaches the limit (i.e. the end). If detecting if it actually reached the limit is required, it is a separate concern, and must be done externally wrt. the InputStream. --- .../no/digipost/io/LimitedInputStream.java | 50 +++++++------- .../digipost/io/LimitedInputStreamTest.java | 66 +++++++++++++++++++ 2 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/main/java/no/digipost/io/LimitedInputStream.java b/src/main/java/no/digipost/io/LimitedInputStream.java index 2a71709..eb91353 100644 --- a/src/main/java/no/digipost/io/LimitedInputStream.java +++ b/src/main/java/no/digipost/io/LimitedInputStream.java @@ -21,6 +21,8 @@ import java.io.InputStream; import java.util.function.Supplier; +import static java.lang.Math.max; +import static java.lang.Math.min; import static no.digipost.DiggExceptions.asUnchecked; /** @@ -98,14 +100,16 @@ public LimitedInputStream(InputStream inputStream, long maxBytesCount, Supplier< */ @Override public int read() throws IOException { + if (count > maxBytesCount) { + return reachedLimit(); + } int res = super.read(); - if (res != -1) { - count++; - if (hasReachedLimit()) { - return -1; - } + count++; + if (res == -1 || count <= maxBytesCount) { + return res; + } else { + return reachedLimit(); } - return res; } /** @@ -133,30 +137,26 @@ public int read() throws IOException { */ @Override public int read(byte[] b, int off, int len) throws IOException { - int res = super.read(b, off, len); - if (res > 0) { - count += res; - if (hasReachedLimit()) { - return -1; - } + int allowedRemaing = (int)(maxBytesCount - count); + int res; + if (allowedRemaing > 0) { + res = super.read(b, off, min(len, allowedRemaing)); + count += max(res, 1); + } else { + res = read(); } return res; } - - private boolean hasReachedLimit() throws IOException { - if (count > maxBytesCount) { - if (throwIfTooManyBytes == SILENTLY_EOF_ON_REACHING_LIMIT) { - return true; - } - Exception tooManyBytes = throwIfTooManyBytes.get(); - if (tooManyBytes instanceof IOException) { - throw (IOException) tooManyBytes; - } else { - throw asUnchecked(tooManyBytes); - } + private int reachedLimit() throws IOException { + if (throwIfTooManyBytes == SILENTLY_EOF_ON_REACHING_LIMIT) { + return -1; + } + Exception tooManyBytes = throwIfTooManyBytes.get(); + if (tooManyBytes instanceof IOException) { + throw (IOException) tooManyBytes; } else { - return false; + throw asUnchecked(tooManyBytes); } } diff --git a/src/test/java/no/digipost/io/LimitedInputStreamTest.java b/src/test/java/no/digipost/io/LimitedInputStreamTest.java index a22b9b5..cd3dd09 100644 --- a/src/test/java/no/digipost/io/LimitedInputStreamTest.java +++ b/src/test/java/no/digipost/io/LimitedInputStreamTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.quicktheories.core.Gen; +import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -38,7 +39,9 @@ import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.quicktheories.QuickTheory.qt; import static org.quicktheories.generators.SourceDSL.integers; @@ -171,4 +174,67 @@ private static byte[] toByteArrayUsingSingleByteReads(InputStream toRead, DataSi return readBytes; } + @Test + void doesNotConsumeMoreFromUnderlyingInputStreamThanGivenLimit() throws IOException { + byte[] readBytes = new byte[3]; + try ( + InputStream threeBytes = new ByteArrayInputStream(new byte[] {65, 66, 67}); + InputStream maxTwoBytes = limit(threeBytes, bytes(2))) { + + assertThat(maxTwoBytes.read(readBytes), is(2)); + } + assertArrayEquals(new byte[] {65, 66, 0}, readBytes); + } + + @Test + void ableToResetBufferedStreamWhenLimitedStreamIsExhausted() throws IOException { + byte[] oneKiloByte = new byte[1024]; + Arrays.fill(oneKiloByte, (byte) 65); + + byte[] readFromLimitedStream = new byte[800]; + byte[] readFromBufferedStream = new byte[oneKiloByte.length]; + int limit = 600; + try ( + InputStream source = new ByteArrayInputStream(oneKiloByte); + InputStream bufferedSource = new BufferedInputStream(source, 400)) { + + bufferedSource.mark(limit); + try (InputStream limitedStream = limit(bufferedSource, DataSize.bytes(limit), () -> new IllegalStateException("Reached limit!"))) { + assertThat(limitedStream.read(readFromLimitedStream), is(limit)); + bufferedSource.reset(); + bufferedSource.read(readFromBufferedStream); + } + } + assertArrayEquals(readFromBufferedStream, oneKiloByte); + assertAll( + () -> assertEquals(readFromLimitedStream[0], (byte)65), + () -> assertEquals(readFromLimitedStream[limit - 1], (byte)65), + () -> assertEquals(readFromLimitedStream[limit], (byte)0), + () -> assertEquals(readFromLimitedStream[readFromLimitedStream.length - 1], (byte)0) + ); + } + + @Test + void rewindWhenReachingLimit() throws IOException { + byte[] twoKiloByte = new byte[2048]; + Arrays.fill(twoKiloByte, (byte) 65); + + int limit = 1024; + try ( + InputStream source = new ByteArrayInputStream(twoKiloByte); + InputStream bufferedSource = new BufferedInputStream(source, 512)) { + + bufferedSource.mark(limit + 1); // <-- :( + try (InputStream limitedStream = limit(bufferedSource, DataSize.bytes(limit))) { + byte[] readFromLimitedStream = toByteArray(limitedStream); + assertThat(readFromLimitedStream.length, is(limit)); + + bufferedSource.reset(); + byte[] readFromBufferedStream = toByteArray(bufferedSource); + assertArrayEquals(readFromBufferedStream, twoKiloByte); + } + } + + } + } From fb5d6c862b826785c2b296c2e91c07843dd0ae3b Mon Sep 17 00:00:00 2001 From: Rune Flobakk Date: Fri, 2 Aug 2024 14:14:23 +0200 Subject: [PATCH 4/4] More tests for LimitedInputStream --- .../no/digipost/io/LimitedInputStream.java | 15 ++- .../digipost/io/LimitedInputStreamTest.java | 95 ++++++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/main/java/no/digipost/io/LimitedInputStream.java b/src/main/java/no/digipost/io/LimitedInputStream.java index eb91353..bec806d 100644 --- a/src/main/java/no/digipost/io/LimitedInputStream.java +++ b/src/main/java/no/digipost/io/LimitedInputStream.java @@ -137,12 +137,21 @@ public int read() throws IOException { */ @Override public int read(byte[] b, int off, int len) throws IOException { - int allowedRemaing = (int)(maxBytesCount - count); + int allowedRemaining = (int)(maxBytesCount - count); + if (len == 0) { + return allowedRemaining < 0 ? reachedLimit() : 0; + } int res; - if (allowedRemaing > 0) { - res = super.read(b, off, min(len, allowedRemaing)); + if (allowedRemaining > 0) { + // reads at most what is allowed according to the limit + res = super.read(b, off, min(len, allowedRemaining)); count += max(res, 1); } else { + // The stream is not allowed to read any more bytes. + // Delegating to the single byte read method which handles + // if the stream is already beyond its set limit, and in + // any case at most reads one byte to determine if it has + // reached the EOF or contains more data. res = read(); } return res; diff --git a/src/test/java/no/digipost/io/LimitedInputStreamTest.java b/src/test/java/no/digipost/io/LimitedInputStreamTest.java index cd3dd09..7aee7ec 100644 --- a/src/test/java/no/digipost/io/LimitedInputStreamTest.java +++ b/src/test/java/no/digipost/io/LimitedInputStreamTest.java @@ -87,6 +87,41 @@ void neverReadsMoreThanTheSetLimit() { } }); } + + @Test + void discardsBytesAfterLimit() throws IOException { + byte[] sixBytes = new byte[] {65, 66, 67, 68, 69, 70}; + try ( + InputStream source = new ByteArrayInputStream(sixBytes); + InputStream limitedToTwoBytes = limit(source, bytes(4))) { + + + byte[] readBytes = new byte[6]; + byte[] expectedEmpty = new byte[2]; + assertAll( + () -> assertThat("first read yields 4 bytes", limitedToTwoBytes.read(readBytes), is(4)), + () -> assertArrayEquals(new byte[] {65, 66, 67, 68, 0, 0}, readBytes), + () -> assertThat("reading single read yields EOF", limitedToTwoBytes.read(), is(-1)), + () -> assertThat("reading chunk yields EOF", limitedToTwoBytes.read(expectedEmpty), is(-1)), + () -> assertArrayEquals(new byte[] {0, 0}, expectedEmpty)); + } + } + + @Test + void readingZeroBytesYieldsZeroUntilEofHasBeenDetectedByANonZeroReadOperation() throws IOException { + byte[] twoBytes = new byte[] {65, 66}; + try ( + InputStream source = new ByteArrayInputStream(twoBytes); + InputStream limitedToOneByte = limit(source, bytes(1))) { + assertThat(limitedToOneByte.read(new byte[0]), is(0)); + limitedToOneByte.read(); + assertThat(limitedToOneByte.read(new byte[0]), is(0)); + + assertThat(limitedToOneByte.read(), is(-1)); + assertThat(limitedToOneByte.read(new byte[0]), is(-1)); + } + } + } @@ -117,6 +152,53 @@ public void wrapsOtherCheckedExceptionsThanIOExceptionAsRuntimeException() throw assertThat(assertThrows(RuntimeException.class, () -> testLimitedStream("xyz", () -> tooManyBytes)), where(Exception::getCause, sameInstance(tooManyBytes))); } + @Test + void cutsOffLastChunkIfReachingLimit() throws IOException { + byte[] sixBytes = new byte[] {65, 66, 67, 68, 69, 70}; + try ( + InputStream source = new ByteArrayInputStream(sixBytes); + InputStream limitedToTwoBytes = limit(source, bytes(4), () -> new IllegalStateException("reached limit!"))) { + + byte[] consumed = new byte[6]; + assertAll( + () -> assertThat(limitedToTwoBytes.read(consumed), is(4)), + () -> assertArrayEquals(new byte[] {65, 66, 67, 68, 0, 0}, consumed), + () -> assertThrows(IllegalStateException.class, () -> limitedToTwoBytes.read(consumed)), + () -> assertArrayEquals(new byte[] {65, 66, 67, 68, 0, 0}, consumed) + ); + } + } + + @Test + void doesNotThrowWhenAttemptingToReadChunkExactlyEndingAtTheLimit() throws IOException { + byte[] sixBytes = new byte[] {65, 66, 67, 68, 69, 70}; + try ( + InputStream source = new ByteArrayInputStream(sixBytes); + InputStream limitedToTwoBytes = limit(source, bytes(6), () -> new IllegalStateException("reached limit!"))) { + + limitedToTwoBytes.read(new byte[3]); + byte[] lastThreeBytes = new byte[3]; + limitedToTwoBytes.read(lastThreeBytes); + assertArrayEquals(new byte[]{68, 69, 70}, lastThreeBytes); + } + } + + @Test + void readingZeroBytesNeverThrowsUntilTryingToActuallyReadNonZeroBytes() throws IOException { + byte[] twoBytes = new byte[] {65, 66}; + try ( + InputStream source = new ByteArrayInputStream(twoBytes); + InputStream limitedToOneByte = limit(source, bytes(1), () -> new IOException("Should not be thrown"))) { + assertThat(limitedToOneByte.read(new byte[0]), is(0)); + limitedToOneByte.read(); + assertThat(limitedToOneByte.read(new byte[0]), is(0)); + assertAll( + () -> assertThrows(IOException.class, () -> limitedToOneByte.read()), + () -> assertThrows(IOException.class, () -> limitedToOneByte.read(new byte[0])), + () -> assertThrows(IOException.class, () -> limitedToOneByte.read(new byte[1]))); + } + } + } @@ -181,9 +263,10 @@ void doesNotConsumeMoreFromUnderlyingInputStreamThanGivenLimit() throws IOExcept InputStream threeBytes = new ByteArrayInputStream(new byte[] {65, 66, 67}); InputStream maxTwoBytes = limit(threeBytes, bytes(2))) { - assertThat(maxTwoBytes.read(readBytes), is(2)); + assertAll( + () -> assertThat(maxTwoBytes.read(readBytes), is(2)), + () -> assertArrayEquals(new byte[] {65, 66, 0}, readBytes)); } - assertArrayEquals(new byte[] {65, 66, 0}, readBytes); } @Test @@ -199,7 +282,7 @@ void ableToResetBufferedStreamWhenLimitedStreamIsExhausted() throws IOException InputStream bufferedSource = new BufferedInputStream(source, 400)) { bufferedSource.mark(limit); - try (InputStream limitedStream = limit(bufferedSource, DataSize.bytes(limit), () -> new IllegalStateException("Reached limit!"))) { + try (InputStream limitedStream = limit(bufferedSource, DataSize.bytes(limit))) { assertThat(limitedStream.read(readFromLimitedStream), is(limit)); bufferedSource.reset(); bufferedSource.read(readFromBufferedStream); @@ -224,6 +307,11 @@ void rewindWhenReachingLimit() throws IOException { InputStream source = new ByteArrayInputStream(twoKiloByte); InputStream bufferedSource = new BufferedInputStream(source, 512)) { + // consuming a LimitedInputStream until EOF requires consuming one "extra" byte + // to determine if the underlying stream is also at EOF or has more data available + // in order to distinguish if it should EOF or throw an exception. It would in theory + // be possible to avoid reading this extra byte if the LimitedInputStream is not + // set to throw an exception on reaching the limit, but currently it does not support this. bufferedSource.mark(limit + 1); // <-- :( try (InputStream limitedStream = limit(bufferedSource, DataSize.bytes(limit))) { byte[] readFromLimitedStream = toByteArray(limitedStream); @@ -234,7 +322,6 @@ void rewindWhenReachingLimit() throws IOException { assertArrayEquals(readFromBufferedStream, twoKiloByte); } } - } }