From 7d06121bae33fb482f58530c3404ee819c7bbb61 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Fri, 3 Oct 2025 20:48:26 +0200 Subject: [PATCH 1/7] ARJ: correct byte accounting and truncation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `getBytesRead()` could drift from the actual archive size after a full read. * Exceptions on truncation errors were inconsistent or missing. * `DataInputStream` (big-endian) forced ad-hoc helpers for ARJ’s little-endian fields. * **Accurate byte accounting:** count all consumed bytes across main/file headers, variable strings, CRCs, extended headers, and file data. `getBytesRead()` now matches the archive length at end-of-stream. * **Consistent truncation handling:** * Truncation in the **main (archive) header**, read during construction, now throws an `ArchiveException` **wrapping** an `EOFException` (cause preserved). * Truncation in **file headers or file data** is propagated as a plain `EOFException` from `getNextEntry()`/`read()`. * **Endianness refactor:** replace `DataInputStream` with `EndianUtils`, removing several bespoke helpers and making intent explicit. * Add assertion that `getBytesRead()` equals the archive size after full consumption. * Parameterized truncation tests at key boundaries (signature, basic/fixed header sizes, end of fixed/basic header, CRC, extended-header length, file data) verifying the exception contract above. --- src/changes/changes.xml | 2 + .../archivers/arj/ArjArchiveInputStream.java | 275 +++++++++--------- .../arj/ArjArchiveInputStreamTest.java | 108 +++++++ 3 files changed, 253 insertions(+), 132 deletions(-) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index 85c50ea0042..9e68ae23da6 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -79,6 +79,8 @@ The type attribute can be add,update,fix,remove. ArArchiveInputStream.readGNUStringTable(byte[], int, int) now provides a better exception message, wrapping the underlying exception. ArArchiveInputStream.read(byte[], int, int) now throws ArchiveException instead of ArithmeticException. Simplify handling of special AR records in ArArchiveInputStream. + + Correct byte accounting and truncation errors in ARJ input stream. org.apache.commons.compress.harmony.unpack200 now throws Pack200Exception, IllegalArgumentException, and IllegalStateException instead of other runtime exceptions and Error. diff --git a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java index 8e127ab8e2c..3849f0bb915 100644 --- a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java @@ -18,9 +18,12 @@ */ package org.apache.commons.compress.archivers.arj; +import static org.apache.commons.io.EndianUtils.readSwappedInteger; +import static org.apache.commons.io.EndianUtils.readSwappedShort; +import static org.apache.commons.io.EndianUtils.readSwappedUnsignedInteger; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; @@ -31,7 +34,7 @@ import org.apache.commons.compress.archivers.ArchiveEntry; import org.apache.commons.compress.archivers.ArchiveException; import org.apache.commons.compress.archivers.ArchiveInputStream; -import org.apache.commons.compress.utils.IOUtils; +import org.apache.commons.io.EndianUtils; import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.input.ChecksumInputStream; @@ -98,7 +101,6 @@ public static boolean matches(final byte[] signature, final int length) { return length >= 2 && (0xff & signature[0]) == ARJ_MAGIC_1 && (0xff & signature[1]) == ARJ_MAGIC_2; } - private final DataInputStream dis; private final MainHeader mainHeader; private LocalFileHeader currentLocalFileHeader; private InputStream currentInputStream; @@ -118,8 +120,7 @@ public ArjArchiveInputStream(final InputStream inputStream) throws ArchiveExcept } private ArjArchiveInputStream(final InputStream inputStream, final Builder builder) throws ArchiveException { - super(new DataInputStream(inputStream), builder); - dis = (DataInputStream) in; + super(inputStream, builder); try { mainHeader = readMainHeader(); if ((mainHeader.arjFlags & MainHeader.Flags.GARBLED) != 0) { @@ -128,6 +129,8 @@ private ArjArchiveInputStream(final InputStream inputStream, final Builder build if ((mainHeader.arjFlags & MainHeader.Flags.VOLUME) != 0) { throw new ArchiveException("Multi-volume ARJ files are unsupported"); } + } catch (final ArchiveException e) { + throw e; } catch (final IOException e) { throw new ArchiveException(e.getMessage(), (Throwable) e); } @@ -151,11 +154,6 @@ public boolean canReadEntryData(final ArchiveEntry ae) { return ae instanceof ArjArchiveEntry && ((ArjArchiveEntry) ae).getMethod() == LocalFileHeader.Methods.STORED; } - @Override - public void close() throws IOException { - dis.close(); - } - /** * Gets the archive's comment. * @@ -188,10 +186,22 @@ public ArjArchiveEntry getNextEntry() throws IOException { currentLocalFileHeader = readLocalFileHeader(); if (currentLocalFileHeader != null) { // @formatter:off + final long currentPosition = getBytesRead(); currentInputStream = BoundedInputStream.builder() - .setInputStream(dis) + .setInputStream(in) .setMaxCount(currentLocalFileHeader.compressedSize) .setPropagateClose(false) + .setAfterRead(read -> { + if (read < 0) { + throw new EOFException(String.format( + "Truncated ARJ archive: entry '%s' expected %,d bytes, but only %,d were read.", + currentLocalFileHeader.name, + currentLocalFileHeader.compressedSize, + getBytesRead() - currentPosition + )); + } + count(read); + }) .get(); // @formatter:on if (currentLocalFileHeader.method == LocalFileHeader.Methods.STORED) { @@ -225,63 +235,72 @@ public int read(final byte[] b, final int off, final int len) throws IOException return currentInputStream.read(b, off, len); } - private int read16(final DataInputStream dataIn) throws IOException { - final int value = dataIn.readUnsignedShort(); - count(2); - return Integer.reverseBytes(value) >>> 16; + private static int readUnsignedByte(InputStream in) throws IOException { + final int value = in.read(); + if (value == -1) { + throw new EOFException(); + } + return value & 0xff; } - private int read32(final DataInputStream dataIn) throws IOException { - final int value = dataIn.readInt(); - count(4); - return Integer.reverseBytes(value); + private int readSwappedUnsignedShort() throws IOException { + final int value = EndianUtils.readSwappedUnsignedShort(in); + count(2); + return value; } - private int read8(final DataInputStream dataIn) throws IOException { - final int value = dataIn.readUnsignedByte(); + private int readUnsignedByte() throws IOException { + final int value = readUnsignedByte(in); count(1); - return value; + return value & 0xff; } - private void readExtraData(final int firstHeaderSize, final DataInputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException { + private static void readExtraData(final int firstHeaderSize, final InputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException { if (firstHeaderSize >= 33) { - localFileHeader.extendedFilePosition = read32(firstHeader); + localFileHeader.extendedFilePosition = readSwappedInteger(firstHeader); if (firstHeaderSize >= 45) { - localFileHeader.dateTimeAccessed = read32(firstHeader); - localFileHeader.dateTimeCreated = read32(firstHeader); - localFileHeader.originalSizeEvenForVolumes = read32(firstHeader); - pushedBackBytes(12); + localFileHeader.dateTimeAccessed = readSwappedInteger(firstHeader); + localFileHeader.dateTimeCreated = readSwappedInteger(firstHeader); + localFileHeader.originalSizeEvenForVolumes = readSwappedInteger(firstHeader); } - pushedBackBytes(4); } } + /** + * Scans for the next valid ARJ header. + * + * @return The header bytes, or {@code null} if end of archive. + * @throws EOFException If the end of the stream is reached before a valid header is found. + * @throws IOException If an I/O error occurs. + */ private byte[] readHeader() throws IOException { - boolean found = false; - byte[] basicHeaderBytes = null; - do { + byte[] basicHeaderBytes; + // TODO: Explain why we are scanning for a valid ARJ header + // and don't throw, when an invalid/corrupted header is found, + // which might indicate a corrupted archive. + while (true) { int first; - int second = read8(dis); + int second = readUnsignedByte(); do { first = second; - second = read8(dis); + second = readUnsignedByte(); } while (first != ARJ_MAGIC_1 && second != ARJ_MAGIC_2); - final int basicHeaderSize = read16(dis); + final int basicHeaderSize = readSwappedUnsignedShort(); if (basicHeaderSize == 0) { // end of archive return null; - } - if (basicHeaderSize <= 2600) { - basicHeaderBytes = readRange(dis, basicHeaderSize); - final long basicHeaderCrc32 = read32(dis) & 0xFFFFFFFFL; + } else if (basicHeaderSize <= 2600) { + basicHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, basicHeaderSize); + count(basicHeaderSize); + final long basicHeaderCrc32 = readSwappedUnsignedInteger(in); + count(4); final CRC32 crc32 = new CRC32(); crc32.update(basicHeaderBytes); if (basicHeaderCrc32 == crc32.getValue()) { - found = true; + return basicHeaderBytes; } } - } while (!found); - return basicHeaderBytes; + } } private LocalFileHeader readLocalFileHeader() throws IOException { @@ -289,100 +308,101 @@ private LocalFileHeader readLocalFileHeader() throws IOException { if (basicHeaderBytes == null) { return null; } - try (DataInputStream basicHeader = new DataInputStream(new ByteArrayInputStream(basicHeaderBytes))) { - - final int firstHeaderSize = basicHeader.readUnsignedByte(); - final byte[] firstHeaderBytes = readRange(basicHeader, firstHeaderSize - 1); - pushedBackBytes(firstHeaderBytes.length); - try (DataInputStream firstHeader = new DataInputStream(new ByteArrayInputStream(firstHeaderBytes))) { - - final LocalFileHeader localFileHeader = new LocalFileHeader(); - localFileHeader.archiverVersionNumber = firstHeader.readUnsignedByte(); - localFileHeader.minVersionToExtract = firstHeader.readUnsignedByte(); - localFileHeader.hostOS = firstHeader.readUnsignedByte(); - localFileHeader.arjFlags = firstHeader.readUnsignedByte(); - localFileHeader.method = firstHeader.readUnsignedByte(); - localFileHeader.fileType = firstHeader.readUnsignedByte(); - localFileHeader.reserved = firstHeader.readUnsignedByte(); - localFileHeader.dateTimeModified = read32(firstHeader); - localFileHeader.compressedSize = 0xffffFFFFL & read32(firstHeader); - localFileHeader.originalSize = 0xffffFFFFL & read32(firstHeader); - localFileHeader.originalCrc32 = 0xffffFFFFL & read32(firstHeader); - localFileHeader.fileSpecPosition = read16(firstHeader); - localFileHeader.fileAccessMode = read16(firstHeader); - pushedBackBytes(20); - localFileHeader.firstChapter = firstHeader.readUnsignedByte(); - localFileHeader.lastChapter = firstHeader.readUnsignedByte(); + final LocalFileHeader localFileHeader = new LocalFileHeader(); + try (InputStream basicHeader = new ByteArrayInputStream(basicHeaderBytes)) { + + final int firstHeaderSize = readUnsignedByte(basicHeader); + try (InputStream firstHeader = BoundedInputStream.builder().setInputStream(basicHeader).setMaxCount(firstHeaderSize - 1).get()) { + + localFileHeader.archiverVersionNumber = readUnsignedByte(firstHeader); + localFileHeader.minVersionToExtract = readUnsignedByte(firstHeader); + localFileHeader.hostOS = readUnsignedByte(firstHeader); + localFileHeader.arjFlags = readUnsignedByte(firstHeader); + localFileHeader.method = readUnsignedByte(firstHeader); + localFileHeader.fileType = readUnsignedByte(firstHeader); + localFileHeader.reserved = readUnsignedByte(firstHeader); + localFileHeader.dateTimeModified = readSwappedInteger(firstHeader); + localFileHeader.compressedSize = readSwappedUnsignedInteger(firstHeader); + localFileHeader.originalSize = readSwappedUnsignedInteger(firstHeader); + localFileHeader.originalCrc32 = readSwappedUnsignedInteger(firstHeader); + localFileHeader.fileSpecPosition = readSwappedShort(firstHeader); + localFileHeader.fileAccessMode = readSwappedShort(firstHeader); + localFileHeader.firstChapter = readUnsignedByte(firstHeader); + localFileHeader.lastChapter = readUnsignedByte(firstHeader); readExtraData(firstHeaderSize, firstHeader, localFileHeader); + } - localFileHeader.name = readString(basicHeader); - localFileHeader.comment = readString(basicHeader); - - final ArrayList extendedHeaders = new ArrayList<>(); - int extendedHeaderSize; - while ((extendedHeaderSize = read16(dis)) > 0) { - final byte[] extendedHeaderBytes = readRange(dis, extendedHeaderSize); - final long extendedHeaderCrc32 = 0xffffFFFFL & read32(dis); - final CRC32 crc32 = new CRC32(); - crc32.update(extendedHeaderBytes); - if (extendedHeaderCrc32 != crc32.getValue()) { - throw new ArchiveException("Extended header CRC32 verification failure"); - } - extendedHeaders.add(extendedHeaderBytes); - } - localFileHeader.extendedHeaders = extendedHeaders.toArray(new byte[0][]); + localFileHeader.name = readString(basicHeader); + localFileHeader.comment = readString(basicHeader); + } - return localFileHeader; + final ArrayList extendedHeaders = new ArrayList<>(); + int extendedHeaderSize; + while ((extendedHeaderSize = readSwappedUnsignedShort()) > 0) { + final byte[] extendedHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, extendedHeaderSize); + count(extendedHeaderSize); + final long extendedHeaderCrc32 = readSwappedUnsignedInteger(in); + count(4); + final CRC32 crc32 = new CRC32(); + crc32.update(extendedHeaderBytes); + if (extendedHeaderCrc32 != crc32.getValue()) { + throw new ArchiveException("Extended header CRC32 verification failure"); } + extendedHeaders.add(extendedHeaderBytes); } + localFileHeader.extendedHeaders = extendedHeaders.toArray(new byte[0][]); + + return localFileHeader; } private MainHeader readMainHeader() throws IOException { - final byte[] basicHeaderBytes = readHeader(); - if (basicHeaderBytes == null) { - throw new ArchiveException("Archive ends without any headers"); + final byte[] basicHeaderBytes; + try { + basicHeaderBytes = readHeader(); + } catch (final EOFException e) { + throw new ArchiveException("Archive ends without any headers", (Throwable) e); } - final DataInputStream basicHeader = new DataInputStream(new ByteArrayInputStream(basicHeaderBytes)); - - final int firstHeaderSize = basicHeader.readUnsignedByte(); - final byte[] firstHeaderBytes = readRange(basicHeader, firstHeaderSize - 1); - pushedBackBytes(firstHeaderBytes.length); - - final DataInputStream firstHeader = new DataInputStream(new ByteArrayInputStream(firstHeaderBytes)); - final MainHeader header = new MainHeader(); - header.archiverVersionNumber = firstHeader.readUnsignedByte(); - header.minVersionToExtract = firstHeader.readUnsignedByte(); - header.hostOS = firstHeader.readUnsignedByte(); - header.arjFlags = firstHeader.readUnsignedByte(); - header.securityVersion = firstHeader.readUnsignedByte(); - header.fileType = firstHeader.readUnsignedByte(); - header.reserved = firstHeader.readUnsignedByte(); - header.dateTimeCreated = read32(firstHeader); - header.dateTimeModified = read32(firstHeader); - header.archiveSize = 0xffffFFFFL & read32(firstHeader); - header.securityEnvelopeFilePosition = read32(firstHeader); - header.fileSpecPosition = read16(firstHeader); - header.securityEnvelopeLength = read16(firstHeader); - pushedBackBytes(20); // count has already counted them via readRange - header.encryptionVersion = firstHeader.readUnsignedByte(); - header.lastChapter = firstHeader.readUnsignedByte(); + try (InputStream basicHeader = new ByteArrayInputStream(basicHeaderBytes)) { + + final int firstHeaderSize = readUnsignedByte(basicHeader); + try (InputStream firstHeader = BoundedInputStream.builder().setInputStream(basicHeader).setMaxCount(firstHeaderSize - 1).get()) { + + header.archiverVersionNumber = readUnsignedByte(firstHeader); + header.minVersionToExtract = readUnsignedByte(firstHeader); + header.hostOS = readUnsignedByte(firstHeader); + header.arjFlags = readUnsignedByte(firstHeader); + header.securityVersion = readUnsignedByte(firstHeader); + header.fileType = readUnsignedByte(firstHeader); + header.reserved = readUnsignedByte(firstHeader); + header.dateTimeCreated = readSwappedInteger(firstHeader); + header.dateTimeModified = readSwappedInteger(firstHeader); + header.archiveSize = readSwappedUnsignedInteger(firstHeader); + header.securityEnvelopeFilePosition = readSwappedInteger(firstHeader); + header.fileSpecPosition = readSwappedShort(firstHeader); + header.securityEnvelopeLength = readSwappedShort(firstHeader); + header.encryptionVersion = readUnsignedByte(firstHeader); + header.lastChapter = readUnsignedByte(firstHeader); + + if (firstHeaderSize >= 33) { + header.arjProtectionFactor = readUnsignedByte(firstHeader); + header.arjFlags2 = readUnsignedByte(firstHeader); + readUnsignedByte(firstHeader); + readUnsignedByte(firstHeader); + } + } - if (firstHeaderSize >= 33) { - header.arjProtectionFactor = firstHeader.readUnsignedByte(); - header.arjFlags2 = firstHeader.readUnsignedByte(); - firstHeader.readUnsignedByte(); - firstHeader.readUnsignedByte(); + header.name = readString(basicHeader); + header.comment = readString(basicHeader); } - header.name = readString(basicHeader); - header.comment = readString(basicHeader); - - final int extendedHeaderSize = read16(dis); + final int extendedHeaderSize = readSwappedUnsignedShort(); if (extendedHeaderSize > 0) { - header.extendedHeaderBytes = readRange(dis, extendedHeaderSize); - final long extendedHeaderCrc32 = 0xffffFFFFL & read32(dis); + header.extendedHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, extendedHeaderSize); + count(extendedHeaderSize); + final long extendedHeaderCrc32 = readSwappedUnsignedInteger(in); + count(4); final CRC32 crc32 = new CRC32(); crc32.update(header.extendedHeaderBytes); if (extendedHeaderCrc32 != crc32.getValue()) { @@ -393,19 +413,10 @@ private MainHeader readMainHeader() throws IOException { return header; } - private byte[] readRange(final InputStream in, final int len) throws IOException { - final byte[] b = IOUtils.readRange(in, len); - count(b.length); - if (b.length < len) { - throw new EOFException(); - } - return b; - } - - private String readString(final DataInputStream dataIn) throws IOException { + private String readString(final InputStream dataIn) throws IOException { try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { int nextByte; - while ((nextByte = dataIn.readUnsignedByte()) != 0) { + while ((nextByte = readUnsignedByte(dataIn)) != 0) { buffer.write(nextByte); } return buffer.toString(getCharset().name()); diff --git a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java index d076c8a3db5..a9a4ab2f749 100644 --- a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java @@ -22,23 +22,29 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Files; +import java.nio.file.Path; import java.util.Calendar; import java.util.TimeZone; import org.apache.commons.compress.AbstractTest; import org.apache.commons.compress.archivers.ArchiveException; import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.output.ByteArrayOutputStream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests {@link ArjArchiveInputStream}. @@ -116,6 +122,19 @@ void testForEach() throws Exception { assertEquals(expected.toString(), result.toString()); } + @ParameterizedTest + @ValueSource(strings = { "bla.arj", "bla.unix.arj" }) + void testGetBytesRead(final String resource) throws IOException { + final Path path = getPath(resource); + try (ArjArchiveInputStream in = ArjArchiveInputStream.builder().setPath(path).get()) { + while (in.getNextEntry() != null) { + // nop + } + final long expected = Files.size(path); + assertEquals(expected, in.getBytesRead(), "getBytesRead() did not return the expected value"); + } + } + @Test void testGetNextEntry() throws Exception { final StringBuilder expected = new StringBuilder(); @@ -276,4 +295,93 @@ void testSingleByteReadConsistentlyReturnsMinusOneAtEof() throws Exception { assertForEach(archive); } } + + /** + * Verifies that reading an ARJ header record cut short at various boundaries + * results in an {@link EOFException}. + * + *

The main archive header is at the beginning of the file. Within that header:

+ *
    + *
  • Basic header size (2 bytes at offsets 0x02–0x03) = {@code 0x002b}.
  • + *
  • Fixed header size (aka {@code first_hdr_size}, 1 byte at 0x04) = {@code 0x22}.
  • + *
  • The archive name and comment C-strings follow the fixed header and complete the basic header.
  • + *
  • A 4-byte basic header CRC-32 follows the basic header.
  • + *
+ * + * @param maxCount absolute truncation point (number of readable bytes from the start of the file) + */ + @ParameterizedTest + @ValueSource(longs = { + // Empty file. + 0, + // Immediately after the 2-byte signature + 0x02, + // Inside / after the basic-header size (2 bytes at 0x02–0x03) + 0x03, 0x04, + // Just after the fixed-header size (1 byte at 0x04) + 0x05, + // End of fixed header (0x04 + first_hdr_size == 0x26) + 0x26, + // End of basic header after filename/comment (0x04 + basic_hdr_size == 0x2f) + 0x2f, + // Inside / after the basic-header CRC-32 (4 bytes) + 0x30, 0x33, + // Inside the extended-header length (2 bytes) + 0x34}) + void testTruncatedMainHeader(long maxCount) throws Exception { + try (InputStream input = BoundedInputStream.builder() + .setURI(getURI("bla.arj")) + .setMaxCount(maxCount) + .get()) { + ArchiveException ex = assertThrows(ArchiveException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); + Throwable cause = ex.getCause(); + assertInstanceOf(EOFException.class, cause, "Expected EOFException as cause of ArchiveException."); + } + } + + /** + * Verifies that reading an ARJ header record cut short at various boundaries + * results in an {@link EOFException}. + * + *

The test archive is crafted so that the local file header of the first entry begins at + * byte offset {@code 0x0035}. Within that header:

+ *
    + *
  • Basic header size (2 bytes at offsets 0x02–0x03) = {@code 0x0039}.
  • + *
  • Fixed header size (aka {@code first_hdr_size}, 1 byte at 0x04) = {@code 0x2E}.
  • + *
  • The filename and comment C-strings follow the fixed header and complete the basic header.
  • + *
  • A 4-byte basic header CRC-32 follows the basic header.
  • + *
+ * + * @param maxCount absolute truncation point (number of readable bytes from the start of the file) + */ + @ParameterizedTest + @ValueSource(longs = { + // Before the local file header signature + 0x35, + // Immediately after the 2-byte signature + 0x35 + 0x02, + // Inside / after the basic-header size (2 bytes at 0x02–0x03) + 0x35 + 0x03, 0x35 + 0x04, + // Just after the fixed-header size (1 byte at 0x04) + 0x35 + 0x05, + // End of fixed header (0x04 + first_hdr_size == 0x32) + 0x35 + 0x32, + // End of basic header after filename/comment (0x04 + basic_hdr_size == 0x3d) + 0x35 + 0x3d, + // Inside / after the basic-header CRC-32 (4 bytes) + 0x35 + 0x3e, 0x35 + 0x41, + // Inside / after the extended-header length (2 bytes) + 0x35 + 0x42, 0x35 + 0x43, + // One byte before the first file’s data + 0x95 + }) + void testTruncatedLocalHeader(long maxCount) throws Exception { + try (InputStream input = BoundedInputStream.builder().setURI(getURI("bla.arj")).setMaxCount(maxCount).get(); + ArjArchiveInputStream archive = ArjArchiveInputStream.builder().setInputStream(input).get()) { + assertThrows(EOFException.class, () -> { + archive.getNextEntry(); + IOUtils.skip(archive, Long.MAX_VALUE); + }); + } + } } From 824465b7ce8b07c3b9f597173e3abf3faa97a331 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 6 Oct 2025 15:49:14 +0200 Subject: [PATCH 2/7] fix: failing legacy test --- .../org/apache/commons/compress/LegacyConstructorsTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/org/apache/commons/compress/LegacyConstructorsTest.java b/src/test/java/org/apache/commons/compress/LegacyConstructorsTest.java index d60863c6e09..5bfb5f333c3 100644 --- a/src/test/java/org/apache/commons/compress/LegacyConstructorsTest.java +++ b/src/test/java/org/apache/commons/compress/LegacyConstructorsTest.java @@ -100,8 +100,7 @@ static Stream testZipConstructors() { void testArjConstructor() throws Exception { try (InputStream inputStream = Files.newInputStream(getPath("bla.arj")); ArjArchiveInputStream archiveInputStream = new ArjArchiveInputStream(inputStream, "US-ASCII")) { - // Arj wraps the input stream in a DataInputStream - assertEquals(inputStream, getNestedInputStream(getNestedInputStream(archiveInputStream))); + assertEquals(inputStream, getNestedInputStream(archiveInputStream)); assertEquals(US_ASCII, archiveInputStream.getCharset()); } } From 7f80ae2d083ab040bbf77446181fe4ad52227b4d Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 6 Oct 2025 16:11:07 +0200 Subject: [PATCH 3/7] fix: checkstyle error --- .../compress/archivers/arj/ArjArchiveInputStreamTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java index a9a4ab2f749..d8d5596caa8 100644 --- a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java @@ -333,8 +333,8 @@ void testTruncatedMainHeader(long maxCount) throws Exception { .setURI(getURI("bla.arj")) .setMaxCount(maxCount) .get()) { - ArchiveException ex = assertThrows(ArchiveException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); - Throwable cause = ex.getCause(); + final ArchiveException ex = assertThrows(ArchiveException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); + final Throwable cause = ex.getCause(); assertInstanceOf(EOFException.class, cause, "Expected EOFException as cause of ArchiveException."); } } From 54a209e3e0793ee086552e354f764d69211b5378 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 6 Oct 2025 18:03:40 +0200 Subject: [PATCH 4/7] fix: remove `EndianUtils` static import The static import makes it harder to distinguish calls that need to count bytes from those that do not. --- .../archivers/arj/ArjArchiveInputStream.java | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java index 3849f0bb915..7068231bef6 100644 --- a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java @@ -18,10 +18,6 @@ */ package org.apache.commons.compress.archivers.arj; -import static org.apache.commons.io.EndianUtils.readSwappedInteger; -import static org.apache.commons.io.EndianUtils.readSwappedShort; -import static org.apache.commons.io.EndianUtils.readSwappedUnsignedInteger; - import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; @@ -257,11 +253,11 @@ private int readUnsignedByte() throws IOException { private static void readExtraData(final int firstHeaderSize, final InputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException { if (firstHeaderSize >= 33) { - localFileHeader.extendedFilePosition = readSwappedInteger(firstHeader); + localFileHeader.extendedFilePosition = EndianUtils.readSwappedInteger(firstHeader); if (firstHeaderSize >= 45) { - localFileHeader.dateTimeAccessed = readSwappedInteger(firstHeader); - localFileHeader.dateTimeCreated = readSwappedInteger(firstHeader); - localFileHeader.originalSizeEvenForVolumes = readSwappedInteger(firstHeader); + localFileHeader.dateTimeAccessed = EndianUtils.readSwappedInteger(firstHeader); + localFileHeader.dateTimeCreated = EndianUtils.readSwappedInteger(firstHeader); + localFileHeader.originalSizeEvenForVolumes = EndianUtils.readSwappedInteger(firstHeader); } } } @@ -292,7 +288,7 @@ private byte[] readHeader() throws IOException { } else if (basicHeaderSize <= 2600) { basicHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, basicHeaderSize); count(basicHeaderSize); - final long basicHeaderCrc32 = readSwappedUnsignedInteger(in); + final long basicHeaderCrc32 = EndianUtils.readSwappedUnsignedInteger(in); count(4); final CRC32 crc32 = new CRC32(); crc32.update(basicHeaderBytes); @@ -321,12 +317,12 @@ private LocalFileHeader readLocalFileHeader() throws IOException { localFileHeader.method = readUnsignedByte(firstHeader); localFileHeader.fileType = readUnsignedByte(firstHeader); localFileHeader.reserved = readUnsignedByte(firstHeader); - localFileHeader.dateTimeModified = readSwappedInteger(firstHeader); - localFileHeader.compressedSize = readSwappedUnsignedInteger(firstHeader); - localFileHeader.originalSize = readSwappedUnsignedInteger(firstHeader); - localFileHeader.originalCrc32 = readSwappedUnsignedInteger(firstHeader); - localFileHeader.fileSpecPosition = readSwappedShort(firstHeader); - localFileHeader.fileAccessMode = readSwappedShort(firstHeader); + localFileHeader.dateTimeModified = EndianUtils.readSwappedInteger(firstHeader); + localFileHeader.compressedSize = EndianUtils.readSwappedUnsignedInteger(firstHeader); + localFileHeader.originalSize = EndianUtils.readSwappedUnsignedInteger(firstHeader); + localFileHeader.originalCrc32 = EndianUtils.readSwappedUnsignedInteger(firstHeader); + localFileHeader.fileSpecPosition = EndianUtils.readSwappedShort(firstHeader); + localFileHeader.fileAccessMode = EndianUtils.readSwappedShort(firstHeader); localFileHeader.firstChapter = readUnsignedByte(firstHeader); localFileHeader.lastChapter = readUnsignedByte(firstHeader); @@ -342,7 +338,7 @@ private LocalFileHeader readLocalFileHeader() throws IOException { while ((extendedHeaderSize = readSwappedUnsignedShort()) > 0) { final byte[] extendedHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, extendedHeaderSize); count(extendedHeaderSize); - final long extendedHeaderCrc32 = readSwappedUnsignedInteger(in); + final long extendedHeaderCrc32 = EndianUtils.readSwappedUnsignedInteger(in); count(4); final CRC32 crc32 = new CRC32(); crc32.update(extendedHeaderBytes); @@ -376,12 +372,12 @@ private MainHeader readMainHeader() throws IOException { header.securityVersion = readUnsignedByte(firstHeader); header.fileType = readUnsignedByte(firstHeader); header.reserved = readUnsignedByte(firstHeader); - header.dateTimeCreated = readSwappedInteger(firstHeader); - header.dateTimeModified = readSwappedInteger(firstHeader); - header.archiveSize = readSwappedUnsignedInteger(firstHeader); - header.securityEnvelopeFilePosition = readSwappedInteger(firstHeader); - header.fileSpecPosition = readSwappedShort(firstHeader); - header.securityEnvelopeLength = readSwappedShort(firstHeader); + header.dateTimeCreated = EndianUtils.readSwappedInteger(firstHeader); + header.dateTimeModified = EndianUtils.readSwappedInteger(firstHeader); + header.archiveSize = EndianUtils.readSwappedUnsignedInteger(firstHeader); + header.securityEnvelopeFilePosition = EndianUtils.readSwappedInteger(firstHeader); + header.fileSpecPosition = EndianUtils.readSwappedShort(firstHeader); + header.securityEnvelopeLength = EndianUtils.readSwappedShort(firstHeader); header.encryptionVersion = readUnsignedByte(firstHeader); header.lastChapter = readUnsignedByte(firstHeader); @@ -401,7 +397,7 @@ private MainHeader readMainHeader() throws IOException { if (extendedHeaderSize > 0) { header.extendedHeaderBytes = org.apache.commons.io.IOUtils.toByteArray(in, extendedHeaderSize); count(extendedHeaderSize); - final long extendedHeaderCrc32 = readSwappedUnsignedInteger(in); + final long extendedHeaderCrc32 = EndianUtils.readSwappedUnsignedInteger(in); count(4); final CRC32 crc32 = new CRC32(); crc32.update(header.extendedHeaderBytes); From d729f4e609ab3a957b44ca81a3b896f272ddce36 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Oct 2025 20:18:26 +0200 Subject: [PATCH 5/7] Fix failing test --- .../compress/archivers/arj/ArjArchiveInputStream.java | 7 +------ .../compress/archivers/arj/ArjArchiveInputStreamTest.java | 6 +----- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java index 639b42464e8..36dc35ab7c3 100644 --- a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java @@ -349,12 +349,7 @@ private LocalFileHeader readLocalFileHeader() throws IOException { } private MainHeader readMainHeader() throws IOException { - final byte[] basicHeaderBytes; - try { - basicHeaderBytes = readHeader(); - } catch (final EOFException e) { - throw new ArchiveException("Archive ends without any headers", (Throwable) e); - } + final byte[] basicHeaderBytes = readHeader(); final MainHeader header = new MainHeader(); try (InputStream basicHeader = new ByteArrayInputStream(basicHeaderBytes)) { diff --git a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java index 1ba1d233f68..ee2cae01b43 100644 --- a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java @@ -22,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -36,7 +35,6 @@ import java.util.TimeZone; import org.apache.commons.compress.AbstractTest; -import org.apache.commons.compress.archivers.ArchiveException; import org.apache.commons.io.IOUtils; import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.output.ByteArrayOutputStream; @@ -324,9 +322,7 @@ void testTruncatedMainHeader(long maxCount) throws Exception { .setURI(getURI("bla.arj")) .setMaxCount(maxCount) .get()) { - final ArchiveException ex = assertThrows(ArchiveException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); - final Throwable cause = ex.getCause(); - assertInstanceOf(EOFException.class, cause, "Expected EOFException as cause of ArchiveException."); + assertThrows(EOFException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); } } From e62420f6997cf73923895ae052f47607aa1e4f5c Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Oct 2025 20:21:39 +0200 Subject: [PATCH 6/7] Sort methods --- .../archivers/arj/ArjArchiveInputStream.java | 74 ++++++++--------- .../arj/ArjArchiveInputStreamTest.java | 82 +++++++++---------- 2 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java index 36dc35ab7c3..7cc555f0d27 100644 --- a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java @@ -99,6 +99,25 @@ public static boolean matches(final byte[] signature, final int length) { return length >= 2 && (0xff & signature[0]) == ARJ_MAGIC_1 && (0xff & signature[1]) == ARJ_MAGIC_2; } + private static void readExtraData(final int firstHeaderSize, final InputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException { + if (firstHeaderSize >= 33) { + localFileHeader.extendedFilePosition = EndianUtils.readSwappedInteger(firstHeader); + if (firstHeaderSize >= 45) { + localFileHeader.dateTimeAccessed = EndianUtils.readSwappedInteger(firstHeader); + localFileHeader.dateTimeCreated = EndianUtils.readSwappedInteger(firstHeader); + localFileHeader.originalSizeEvenForVolumes = EndianUtils.readSwappedInteger(firstHeader); + } + } + } + + private static int readUnsignedByte(InputStream in) throws IOException { + final int value = in.read(); + if (value == -1) { + throw new EOFException(); + } + return value & 0xff; + } + private final MainHeader mainHeader; private LocalFileHeader currentLocalFileHeader; private InputStream currentInputStream; @@ -227,35 +246,14 @@ public int read(final byte[] b, final int off, final int len) throws IOException return currentInputStream.read(b, off, len); } - private static int readUnsignedByte(InputStream in) throws IOException { - final int value = in.read(); - if (value == -1) { - throw new EOFException(); - } - return value & 0xff; - } - - private int readSwappedUnsignedShort() throws IOException { - final int value = EndianUtils.readSwappedUnsignedShort(in); - count(2); - return value; - } - - private int readUnsignedByte() throws IOException { - final int value = readUnsignedByte(in); - count(1); - return value & 0xff; + private String readComment(final InputStream dataIn) throws IOException { + return new String(readString(dataIn).toByteArray(), getCharset()); } - private static void readExtraData(final int firstHeaderSize, final InputStream firstHeader, final LocalFileHeader localFileHeader) throws IOException { - if (firstHeaderSize >= 33) { - localFileHeader.extendedFilePosition = EndianUtils.readSwappedInteger(firstHeader); - if (firstHeaderSize >= 45) { - localFileHeader.dateTimeAccessed = EndianUtils.readSwappedInteger(firstHeader); - localFileHeader.dateTimeCreated = EndianUtils.readSwappedInteger(firstHeader); - localFileHeader.originalSizeEvenForVolumes = EndianUtils.readSwappedInteger(firstHeader); - } - } + private String readEntryName(final InputStream dataIn) throws IOException { + final ByteArrayOutputStream buffer = readString(dataIn); + ArchiveUtils.checkEntryNameLength(buffer.size(), getMaxEntryNameLength(), "ARJ"); + return new String(buffer.toByteArray(), getCharset()); } /** @@ -409,16 +407,6 @@ private byte[] readRange(final InputStream in, final int len) throws IOException return b; } - private String readComment(final InputStream dataIn) throws IOException { - return new String(readString(dataIn).toByteArray(), getCharset()); - } - - private String readEntryName(final InputStream dataIn) throws IOException { - final ByteArrayOutputStream buffer = readString(dataIn); - ArchiveUtils.checkEntryNameLength(buffer.size(), getMaxEntryNameLength(), "ARJ"); - return new String(buffer.toByteArray(), getCharset()); - } - private ByteArrayOutputStream readString(final InputStream dataIn) throws IOException { try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { int nextByte; @@ -428,4 +416,16 @@ private ByteArrayOutputStream readString(final InputStream dataIn) throws IOExce return buffer; } } + + private int readSwappedUnsignedShort() throws IOException { + final int value = EndianUtils.readSwappedUnsignedShort(in); + count(2); + return value; + } + + private int readUnsignedByte() throws IOException { + final int value = readUnsignedByte(in); + count(1); + return value & 0xff; + } } diff --git a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java index ee2cae01b43..25a66622053 100644 --- a/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStreamTest.java @@ -285,47 +285,6 @@ void testSingleByteReadConsistentlyReturnsMinusOneAtEof() throws Exception { } } - /** - * Verifies that reading an ARJ header record cut short at various boundaries - * results in an {@link EOFException}. - * - *

The main archive header is at the beginning of the file. Within that header:

- *
    - *
  • Basic header size (2 bytes at offsets 0x02–0x03) = {@code 0x002b}.
  • - *
  • Fixed header size (aka {@code first_hdr_size}, 1 byte at 0x04) = {@code 0x22}.
  • - *
  • The archive name and comment C-strings follow the fixed header and complete the basic header.
  • - *
  • A 4-byte basic header CRC-32 follows the basic header.
  • - *
- * - * @param maxCount absolute truncation point (number of readable bytes from the start of the file) - */ - @ParameterizedTest - @ValueSource(longs = { - // Empty file. - 0, - // Immediately after the 2-byte signature - 0x02, - // Inside / after the basic-header size (2 bytes at 0x02–0x03) - 0x03, 0x04, - // Just after the fixed-header size (1 byte at 0x04) - 0x05, - // End of fixed header (0x04 + first_hdr_size == 0x26) - 0x26, - // End of basic header after filename/comment (0x04 + basic_hdr_size == 0x2f) - 0x2f, - // Inside / after the basic-header CRC-32 (4 bytes) - 0x30, 0x33, - // Inside the extended-header length (2 bytes) - 0x34}) - void testTruncatedMainHeader(long maxCount) throws Exception { - try (InputStream input = BoundedInputStream.builder() - .setURI(getURI("bla.arj")) - .setMaxCount(maxCount) - .get()) { - assertThrows(EOFException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); - } - } - /** * Verifies that reading an ARJ header record cut short at various boundaries * results in an {@link EOFException}. @@ -371,4 +330,45 @@ void testTruncatedLocalHeader(long maxCount) throws Exception { }); } } + + /** + * Verifies that reading an ARJ header record cut short at various boundaries + * results in an {@link EOFException}. + * + *

The main archive header is at the beginning of the file. Within that header:

+ *
    + *
  • Basic header size (2 bytes at offsets 0x02–0x03) = {@code 0x002b}.
  • + *
  • Fixed header size (aka {@code first_hdr_size}, 1 byte at 0x04) = {@code 0x22}.
  • + *
  • The archive name and comment C-strings follow the fixed header and complete the basic header.
  • + *
  • A 4-byte basic header CRC-32 follows the basic header.
  • + *
+ * + * @param maxCount absolute truncation point (number of readable bytes from the start of the file) + */ + @ParameterizedTest + @ValueSource(longs = { + // Empty file. + 0, + // Immediately after the 2-byte signature + 0x02, + // Inside / after the basic-header size (2 bytes at 0x02–0x03) + 0x03, 0x04, + // Just after the fixed-header size (1 byte at 0x04) + 0x05, + // End of fixed header (0x04 + first_hdr_size == 0x26) + 0x26, + // End of basic header after filename/comment (0x04 + basic_hdr_size == 0x2f) + 0x2f, + // Inside / after the basic-header CRC-32 (4 bytes) + 0x30, 0x33, + // Inside the extended-header length (2 bytes) + 0x34}) + void testTruncatedMainHeader(long maxCount) throws Exception { + try (InputStream input = BoundedInputStream.builder() + .setURI(getURI("bla.arj")) + .setMaxCount(maxCount) + .get()) { + assertThrows(EOFException.class, () -> ArjArchiveInputStream.builder().setInputStream(input).get()); + } + } } From 6a510ea30654f3f93bd4639d7612c05eb515d06f Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Oct 2025 20:38:58 +0200 Subject: [PATCH 7/7] Remove unused method --- .../compress/archivers/arj/ArjArchiveInputStream.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java index 7cc555f0d27..01984c253ad 100644 --- a/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/arj/ArjArchiveInputStream.java @@ -31,7 +31,6 @@ import org.apache.commons.compress.archivers.ArchiveException; import org.apache.commons.compress.archivers.ArchiveInputStream; import org.apache.commons.compress.utils.ArchiveUtils; -import org.apache.commons.compress.utils.IOUtils; import org.apache.commons.io.EndianUtils; import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.input.ChecksumInputStream; @@ -398,15 +397,6 @@ private MainHeader readMainHeader() throws IOException { return header; } - private byte[] readRange(final InputStream in, final int len) throws IOException { - final byte[] b = IOUtils.readRange(in, len); - count(b.length); - if (b.length < len) { - throw new EOFException(); - } - return b; - } - private ByteArrayOutputStream readString(final InputStream dataIn) throws IOException { try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { int nextByte;