This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-compress.git


The following commit(s) were added to refs/heads/master by this push:
     new 7e688ebdb Avoid unbounded recursion on empty frames in framed lz4 and 
snappy (#803)
7e688ebdb is described below

commit 7e688ebdba45ab16aa5b7f870474dac285a494af
Author: KALI 834X <[email protected]>
AuthorDate: Mon Aug 17 16:35:59 2026 +0530

    Avoid unbounded recursion on empty frames in framed lz4 and snappy (#803)
---
 .../lz4/FramedLZ4CompressorInputStream.java        | 63 +++++++-------
 .../snappy/FramedSnappyCompressorInputStream.java  | 95 ++++++++++++----------
 .../lz4/FramedLZ4CompressorInputStreamTest.java    | 21 +++++
 .../FramedSnappyCompressorInputStreamTest.java     | 18 ++++
 4 files changed, 124 insertions(+), 73 deletions(-)

diff --git 
a/src/main/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStream.java
 
b/src/main/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStream.java
index 923cc8938..11a0f1581 100644
--- 
a/src/main/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStream.java
+++ 
b/src/main/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStream.java
@@ -193,39 +193,46 @@ private void maybeFinishCurrentBlock() throws IOException 
{
     }
 
     private void nextBlock() throws IOException {
-        maybeFinishCurrentBlock();
-        final long len = ByteUtils.fromLittleEndian(supplier, 4);
-        final boolean uncompressed = (len & UNCOMPRESSED_FLAG_MASK) != 0;
-        final int realLen = (int) (len & ~UNCOMPRESSED_FLAG_MASK);
-        if (realLen == 0) {
+        while (true) {
+            maybeFinishCurrentBlock();
+            final long len = ByteUtils.fromLittleEndian(supplier, 4);
+            final boolean uncompressed = (len & UNCOMPRESSED_FLAG_MASK) != 0;
+            final int realLen = (int) (len & ~UNCOMPRESSED_FLAG_MASK);
+            if (realLen != 0) {
+                // @formatter:off
+                InputStream capped = BoundedInputStream.builder()
+                        .setInputStream(inputStream)
+                        .setMaxCount(realLen)
+                        .setPropagateClose(false)
+                        .get();
+                // @formatter:on
+                if (expectBlockChecksum) {
+                    capped = new CheckedInputStream(capped, blockHash);
+                }
+                if (uncompressed) {
+                    inUncompressed = true;
+                    currentBlock = capped;
+                } else {
+                    inUncompressed = false;
+                    final BlockLZ4CompressorInputStream s = new 
BlockLZ4CompressorInputStream(capped);
+                    if (expectBlockDependency) {
+                        s.prefill(blockDependencyBuffer);
+                    }
+                    currentBlock = s;
+                }
+                return;
+            }
             verifyContentChecksum();
             if (!decompressConcatenated) {
                 endReached = true;
-            } else {
-                init(false);
+                return;
             }
-            return;
-        }
-        // @formatter:off
-        InputStream capped = BoundedInputStream.builder()
-                .setInputStream(inputStream)
-                .setMaxCount(realLen)
-                .setPropagateClose(false)
-                .get();
-        // @formatter:on
-        if (expectBlockChecksum) {
-            capped = new CheckedInputStream(capped, blockHash);
-        }
-        if (uncompressed) {
-            inUncompressed = true;
-            currentBlock = capped;
-        } else {
-            inUncompressed = false;
-            final BlockLZ4CompressorInputStream s = new 
BlockLZ4CompressorInputStream(capped);
-            if (expectBlockDependency) {
-                s.prefill(blockDependencyBuffer);
+            // Start of the next concatenated frame. Loop instead of recursing 
into init so a run of empty
+            // frames advances without growing the call stack.
+            if (!readSignature(false)) {
+                return;
             }
-            currentBlock = s;
+            readFrameDescriptor();
         }
     }
 
diff --git 
a/src/main/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStream.java
 
b/src/main/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStream.java
index 69f143e52..b3ab270b5 100644
--- 
a/src/main/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStream.java
+++ 
b/src/main/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStream.java
@@ -226,53 +226,58 @@ private long readCrc() throws IOException {
     }
 
     private void readNextBlock() throws IOException {
-        verifyLastChecksumAndReset();
-        inUncompressedChunk = false;
-        final int type = readOneByte();
-        if (type == -1) {
-            endReached = true;
-        } else if (type == STREAM_IDENTIFIER_TYPE) {
-            inputStream.unread(type);
-            unreadBytes++;
-            pushedBackBytes(1);
-            readStreamIdentifier();
-            readNextBlock();
-        } else if (type == PADDING_CHUNK_TYPE || type > MAX_UNSKIPPABLE_TYPE 
&& type <= MAX_SKIPPABLE_TYPE) {
-            skipBlock();
-            readNextBlock();
-        } else if (type >= MIN_UNSKIPPABLE_TYPE && type <= 
MAX_UNSKIPPABLE_TYPE) {
-            throw new CompressorException("Unskippable chunk with type %s (hex 
%s) detected.", type, Integer.toHexString(type));
-        } else if (type == UNCOMPRESSED_CHUNK_TYPE) {
-            inUncompressedChunk = true;
-            uncompressedBytesRemaining = readSize() - 4 /* CRC */;
-            if (uncompressedBytesRemaining < 0) {
-                throw new CompressorException("Found illegal chunk with 
negative size");
-            }
-            expectedChecksum = unmask(readCrc());
-        } else if (type == COMPRESSED_CHUNK_TYPE) {
-            final boolean expectChecksum = 
dialect.usesChecksumWithCompressedChunks();
-            final long size = readSize() - (expectChecksum ? 4L : 0L);
-            if (size < 0) {
-                throw new CompressorException("Found illegal chunk with 
negative size");
-            }
-            if (expectChecksum) {
-                expectedChecksum = unmask(readCrc());
+        while (true) {
+            verifyLastChecksumAndReset();
+            inUncompressedChunk = false;
+            final int type = readOneByte();
+            // Stream identifiers and padding/skippable chunks produce no 
output. Loop instead of recursing
+            // so a run of them advances without growing the call stack.
+            if (type == STREAM_IDENTIFIER_TYPE) {
+                inputStream.unread(type);
+                unreadBytes++;
+                pushedBackBytes(1);
+                readStreamIdentifier();
+            } else if (type == PADDING_CHUNK_TYPE || type > 
MAX_UNSKIPPABLE_TYPE && type <= MAX_SKIPPABLE_TYPE) {
+                skipBlock();
             } else {
-                expectedChecksum = -1;
+                if (type == -1) {
+                    endReached = true;
+                } else if (type >= MIN_UNSKIPPABLE_TYPE && type <= 
MAX_UNSKIPPABLE_TYPE) {
+                    throw new CompressorException("Unskippable chunk with type 
%s (hex %s) detected.", type, Integer.toHexString(type));
+                } else if (type == UNCOMPRESSED_CHUNK_TYPE) {
+                    inUncompressedChunk = true;
+                    uncompressedBytesRemaining = readSize() - 4 /* CRC */;
+                    if (uncompressedBytesRemaining < 0) {
+                        throw new CompressorException("Found illegal chunk 
with negative size");
+                    }
+                    expectedChecksum = unmask(readCrc());
+                } else if (type == COMPRESSED_CHUNK_TYPE) {
+                    final boolean expectChecksum = 
dialect.usesChecksumWithCompressedChunks();
+                    final long size = readSize() - (expectChecksum ? 4L : 0L);
+                    if (size < 0) {
+                        throw new CompressorException("Found illegal chunk 
with negative size");
+                    }
+                    if (expectChecksum) {
+                        expectedChecksum = unmask(readCrc());
+                    } else {
+                        expectedChecksum = -1;
+                    }
+                    // @formatter:off
+                    currentCompressedChunk = new 
SnappyCompressorInputStream(BoundedInputStream.builder()
+                            .setInputStream(inputStream)
+                            .setMaxCount(size)
+                            .setPropagateClose(false)
+                            .get(),
+                            blockSize);
+                    // @formatter:on
+                    // constructor reads uncompressed size
+                    count(currentCompressedChunk.getBytesRead());
+                } else {
+                    // impossible as all potential byte values have been 
covered
+                    throw new CompressorException("Unknown chunk type %s 
detected.", type);
+                }
+                return;
             }
-            // @formatter:off
-            currentCompressedChunk = new 
SnappyCompressorInputStream(BoundedInputStream.builder()
-                    .setInputStream(inputStream)
-                    .setMaxCount(size)
-                    .setPropagateClose(false)
-                    .get(),
-                    blockSize);
-            // @formatter:on
-            // constructor reads uncompressed size
-            count(currentCompressedChunk.getBytesRead());
-        } else {
-            // impossible as all potential byte values have been covered
-            throw new CompressorException("Unknown chunk type %s detected.", 
type);
         }
     }
 
diff --git 
a/src/test/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStreamTest.java
 
b/src/test/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStreamTest.java
index c5af8c97b..63ad2c65e 100644
--- 
a/src/test/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStreamTest.java
+++ 
b/src/test/java/org/apache/commons/compress/compressors/lz4/FramedLZ4CompressorInputStreamTest.java
@@ -19,6 +19,7 @@
 package org.apache.commons.compress.compressors.lz4;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertThrows;
@@ -27,6 +28,7 @@
 
 import java.io.BufferedInputStream;
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -551,6 +553,25 @@ void testSkipsOverSkippableFrames() throws IOException {
         }
     }
 
+    @Test
+    void testManyEmptyConcatenatedFramesDoNotOverflowStack() throws 
IOException {
+        final byte[] singleEmptyFrame;
+        try (ByteArrayOutputStream frame = new ByteArrayOutputStream()) {
+            new FramedLZ4CompressorOutputStream(frame).close();
+            singleEmptyFrame = frame.toByteArray();
+        }
+        final ByteArrayOutputStream concatenated = new ByteArrayOutputStream();
+        for (int i = 0; i < 200_000; i++) {
+            concatenated.write(singleEmptyFrame);
+        }
+        final byte[] input = concatenated.toByteArray();
+        assertDoesNotThrow(() -> {
+            try (FramedLZ4CompressorInputStream in = new 
FramedLZ4CompressorInputStream(new ByteArrayInputStream(input), true)) {
+                assertEquals(-1, in.read());
+            }
+        });
+    }
+
     @Test
     void testSkipsOverTrailingSkippableFrames() throws IOException {
         final byte[] input = { 4, 0x22, 0x4d, 0x18, // signature
diff --git 
a/src/test/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStreamTest.java
 
b/src/test/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStreamTest.java
index ab96fd91e..64edb039a 100644
--- 
a/src/test/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStreamTest.java
+++ 
b/src/test/java/org/apache/commons/compress/compressors/snappy/FramedSnappyCompressorInputStreamTest.java
@@ -19,6 +19,7 @@
 package org.apache.commons.compress.compressors.snappy;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertThrows;
@@ -237,4 +238,21 @@ void testWriteDataLargerThanBufferOneCall() throws 
IOException {
         assertArrayEquals(data, decompressed);
     }
 
+    @Test
+    void testManyPaddingChunksDoNotOverflowStack() {
+        final byte[] streamIdentifier = { (byte) 0xff, 6, 0, 0, 's', 'N', 'a', 
'P', 'p', 'Y' };
+        final byte[] emptyPaddingChunk = { (byte) 0xfe, 0, 0, 0 };
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        out.write(streamIdentifier, 0, streamIdentifier.length);
+        for (int i = 0; i < 200_000; i++) {
+            out.write(emptyPaddingChunk, 0, emptyPaddingChunk.length);
+        }
+        final byte[] input = out.toByteArray();
+        assertDoesNotThrow(() -> {
+            try (FramedSnappyCompressorInputStream in = new 
FramedSnappyCompressorInputStream(new ByteArrayInputStream(input))) {
+                assertEquals(-1, in.read());
+            }
+        });
+    }
+
 }

Reply via email to