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 12ba75437 Verify Kraft's inequality is not violated (#804)
12ba75437 is described below
commit 12ba75437f4d935cc54806bd27e92314270d5c84
Author: Fredrik Kjellberg <[email protected]>
AuthorDate: Sat Aug 22 21:20:48 2026 +0200
Verify Kraft's inequality is not violated (#804)
Generated-by: Claude Opus 5 <[email protected]>
---
.../commons/compress/huffman/HuffmanDecoder.java | 39 +++++++++++----
.../bzip2/BZip2CompressorInputStreamTest.java | 24 ++++++---
.../Deflate64CompressorInputStreamTest.java | 6 ++-
.../compress/huffman/HuffmanDecoderTest.java | 58 ++++++++++++++++++++--
4 files changed, 105 insertions(+), 22 deletions(-)
diff --git
a/src/main/java/org/apache/commons/compress/huffman/HuffmanDecoder.java
b/src/main/java/org/apache/commons/compress/huffman/HuffmanDecoder.java
index ffd022c2a..3f444e88e 100644
--- a/src/main/java/org/apache/commons/compress/huffman/HuffmanDecoder.java
+++ b/src/main/java/org/apache/commons/compress/huffman/HuffmanDecoder.java
@@ -30,17 +30,20 @@
/**
* Canonical Huffman decoder.
* <p>
- * This class builds decoding tables from an array of code lengths (one entry
per symbol) and then decodes symbols from a {@link BitInputStream}. The code set
- * is expected to be a <em>complete prefix code</em>; i.e., the code lengths
must satisfy Kraft's equality.
+ * This class builds decoding tables from an array of code lengths (one entry
per symbol) and then decodes symbols from a {@link BitInputStream}.
+ * </p>
+ * <p>
+ * The constructors verify that the code lengths satisfy <em>Kraft's
inequality</em>, i.e. that no code length has more leaf nodes assigned to it
than a binary
+ * tree of that depth can hold, and reject over-subscribed code sets with a
{@link CompressorException}. Code sets that leave leaf nodes unused, i.e.
incomplete
+ * codes, are accepted, as several formats produce them. Decoding a code word
that no symbol claims fails at {@link #decodeSymbol(BitInputStream)} time.
* </p>
*
* <h2>Usage</h2>
*
* <pre>{@code
* int[] codeLengths = ...; // length per symbol (0 => unused)
- * int symbolCount = codeLengths.length;
* int maxLen = 15; // maximum non-zero code length in codeLengths
- * HuffmanDecoder dec = new HuffmanDecoder(codeLengths, symbolCount, maxLen);
+ * HuffmanDecoder dec = new HuffmanDecoder(codeLengths, 0, maxLen);
* int sym = dec.decodeSymbol(bitIn);
* }</pre>
*
@@ -57,9 +60,11 @@ public final class HuffmanDecoder {
/**
* Builds canonical decode tables.
+ *
+ * @throws CompressorException if the code lengths violate Kraft's
inequality.
*/
private static void fillCodeTable(final int[] codeLengths, final int
minLen, final int maxLen, final int[] bias,
- final int[] limit, final int[] sorted) {
+ final int[] limit, final int[] sorted) throws CompressorException {
// 1) Histogram of code lengths
final int[] count = new int[maxLen + 1];
for (int symbol = 0; symbol < codeLengths.length; symbol++) {
@@ -69,14 +74,24 @@ private static void fillCodeTable(final int[] codeLengths,
final int minLen, fin
}
count[codeLengths[symbol]]++;
}
- // 2) Generate starting offsets into sorted symbol table
+ // 2) Verify Kraft's inequality is not violated, i.e. the tree has no
more leaf nodes at any depth than fit in a binary tree of that depth.
+ int availableNodes = 1;
+ for (int len = 1; len <= maxLen; len++) {
+ availableNodes <<= 1;
+ if (count[len] > availableNodes) {
+ throw new CompressorException("Tree contains too many leaf
nodes for code length %d: %d leaf nodes, but only %d nodes available", len,
+ count[len], availableNodes);
+ }
+ availableNodes -= count[len];
+ }
+ // 3) Generate starting offsets into sorted symbol table
// The offsets are biased by -1 to simplify code in the next step
final int[] offset = new int[maxLen + 1];
offset[0] = -1;
for (int len = 1; len <= maxLen; len++) {
offset[len] = offset[len - 1] + count[len - 1];
}
- // 3) Build table of symbols sorted by length, then by symbol
+ // 4) Build table of symbols sorted by length, then by symbol
// Adjust offsets to point to the last element of each length
for (int symbol = 0; symbol < codeLengths.length; symbol++) {
final int len = codeLengths[symbol];
@@ -85,14 +100,14 @@ private static void fillCodeTable(final int[] codeLengths,
final int minLen, fin
}
sorted[++offset[len]] = symbol;
}
- // 4) Compute the largest left-justified code for each length
+ // 5) Compute the largest left-justified code for each length
int firstCode = 0;
for (int len = minLen; len <= maxLen; len++) {
firstCode += count[len];
limit[len] = firstCode - 1;
firstCode <<= 1; // prepare for next length
}
- // 5) Compute the bias for each length
+ // 6) Compute the bias for each length
for (int len = minLen; len <= maxLen; len++) {
bias[len] = limit[len] - offset[len];
}
@@ -145,7 +160,8 @@ private static int readBitsFully(final BitInputStream in,
final int numBits) thr
*
* @param codeLengths code length per symbol; {@code 0} means the symbol
is not used; not {@code null}.
* @throws NullPointerException if {@code codeLengths} is {@code null}.
- * @throws CompressorException if {@code codeLengths} size is out of
range or if any code length is out of range
+ * @throws CompressorException if {@code codeLengths} size is out of
range or if any code length is out of range or if the code lengths violate
+ * Kraft's inequality.
*/
public HuffmanDecoder(final int[] codeLengths) throws CompressorException {
this(codeLengths, 0, MAX_SUPPORTED_CODE_LENGTH);
@@ -164,7 +180,8 @@ public HuffmanDecoder(final int[] codeLengths) throws
CompressorException {
* @throws NullPointerException if {@code codeLengths} is {@code null}.
* @throws IllegalArgumentException if {@code maxCodeLength} exceeds the
implementation limit (30) or if {@code minCodeLength}
* is not in the range 0-{@code
maxCodeLength}].
- * @throws CompressorException if {@code codeLengths} size is out of
range or if any code length is out of range
+ * @throws CompressorException if {@code codeLengths} size is out of
range or if any code length is out of range or if the code lengths violate
+ * Kraft's inequality.
*/
public HuffmanDecoder(final int[] codeLengths, final int minCodeLength,
final int maxCodeLength) throws CompressorException {
Objects.requireNonNull(codeLengths, "codeLengths");
diff --git
a/src/test/java/org/apache/commons/compress/compressors/bzip2/BZip2CompressorInputStreamTest.java
b/src/test/java/org/apache/commons/compress/compressors/bzip2/BZip2CompressorInputStreamTest.java
index 1c1607fe3..5fb25169d 100644
---
a/src/test/java/org/apache/commons/compress/compressors/bzip2/BZip2CompressorInputStreamTest.java
+++
b/src/test/java/org/apache/commons/compress/compressors/bzip2/BZip2CompressorInputStreamTest.java
@@ -65,10 +65,14 @@ private void fuzzingTest(final int[] bytes) throws
IOException, ArchiveException
* <li>Number of groups: 2 (minimum).</li>
* <li>Number of selectors: 3.</li>
* <li>Selectors: all three encode j=1 (unary "10").</li>
- * <li>Huffman code lengths for 2 groups over alphabet size 3 (RUNA,
RUNB, EOB) are all equal to {@code codeLength}.</li>
+ * <li>Huffman code lengths for 2 groups over alphabet size 3 (RUNA,
RUNB, EOB): {@code codeLength} is used for every
+ * symbol, except when it equals {@link #MIN_CODE_LEN}, in which case
lengths {@code {1, 2, 2}} are used instead
+ * because three symbols sharing length 1 would violate Kraft's
inequality; the minimum length is still
+ * {@code codeLength} in that case.</li>
* </ul>
* <p>
- * <strong>Note:</strong> The values are chosen to keep everything
byte-aligned.
+ * Each Huffman group is encoded as a 5-bit start length followed, for
each symbol, by a delta encoding: pairs of a
+ * '1' bit and a direction bit ('0' increments the current length, '1'
decrements it), terminated by a '0' bit.
* </p>
* @param codeLength The code length to use for each symbol in each group;
must be in [0, 31]
*/
@@ -91,10 +95,18 @@ private BitInputStream prepareDecodingTables(final int
codeLength) {
stream.write(0b00000000); // middle 8 bits of nSelectors
stream.write(0b11_10_10_10); // low 2 bits of nSelectors + selectors
(3 x 2 bits)
- // Huffman tables: two groups, three symbols each
- // startLen (5 bits) followed by 3x '0' (done) => one byte: codeLength
<< 3
- stream.write(codeLength << 3);
- stream.write(codeLength << 3);
+ // Huffman tables: two groups, three symbols each.
+ if (codeLength == MIN_CODE_LEN) {
+ // Lengths {1, 2, 2}, encoded per group as: 00001 (startLen 1) 0
(keep) 10 0 (increment, then keep) 0 (keep).
+ // Two groups of those 10 bits are "0000101000 0000101000", which
re-split into bytes and zero-padded gives:
+ stream.write(0b0000_1010);
+ stream.write(0b0000_0010);
+ stream.write(0b1000_0000);
+ } else {
+ // All three symbols share codeLength: 5-bit startLen followed by
three '0' bits, exactly one byte per group.
+ stream.write(codeLength << 3);
+ stream.write(codeLength << 3);
+ }
return new BitInputStream(new
ByteArrayInputStream(stream.toByteArray()), ByteOrder.BIG_ENDIAN);
}
diff --git
a/src/test/java/org/apache/commons/compress/compressors/deflate64/Deflate64CompressorInputStreamTest.java
b/src/test/java/org/apache/commons/compress/compressors/deflate64/Deflate64CompressorInputStreamTest.java
index dfc0f87ef..d9013d9a6 100644
---
a/src/test/java/org/apache/commons/compress/compressors/deflate64/Deflate64CompressorInputStreamTest.java
+++
b/src/test/java/org/apache/commons/compress/compressors/deflate64/Deflate64CompressorInputStreamTest.java
@@ -183,11 +183,15 @@ void
testShouldThrowIOExceptionInsteadOfRuntimeExceptionCOMPRESS526() {
}
/**
+ * The fuzzed data below encodes a Huffman table that violates Kraft's
inequality (too many leaf nodes for its
+ * depth), which {@link
org.apache.commons.compress.huffman.HuffmanDecoder} rejects with a
+ * {@link CompressorException} before the stream would otherwise run out
of data.
+ *
* @see <a
href="https://issues.apache.org/jira/browse/COMPRESS-527">COMPRESS-527</a>
*/
@Test
void testShouldThrowIOExceptionInsteadOfRuntimeExceptionCOMPRESS527() {
- assertThrows(EOFException.class,
+ assertThrows(CompressorException.class,
() -> fuzzingTest(new int[] { 0x50, 0x4b, 0x03, 0x04, 0x14,
0x00, 0x00, 0x00, 0x09, 0x00, 0x84, 0xb6, 0xba, 0x46, 0x72, 0xb6, 0xfe, 0x77,
0x4a,
0x00, 0x00, 0x00, 0x6b, 0x00, 0x00, 0x00, 0x03, 0x00,
0x1c, 0x00, 0x62, 0x62, 0x62, 0x55, 0x54, 0x09, 0x00, 0x03, 0xe7, 0xce, 0x64,
0x55, 0xf3, 0xce, 0x64, 0x55, 0x75, 0x78, 0x0b, 0x00,
0x01, 0x04, 0x5c, 0xf9, 0x01, 0x00, 0x04, 0x88, 0x13, 0x00, 0x00, 0x1d, 0x8b,
diff --git
a/src/test/java/org/apache/commons/compress/huffman/HuffmanDecoderTest.java
b/src/test/java/org/apache/commons/compress/huffman/HuffmanDecoderTest.java
index f44215e58..1a20c8cd0 100644
--- a/src/test/java/org/apache/commons/compress/huffman/HuffmanDecoderTest.java
+++ b/src/test/java/org/apache/commons/compress/huffman/HuffmanDecoderTest.java
@@ -36,6 +36,7 @@
import org.apache.commons.compress.AbstractTest;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.utils.BitInputStream;
+import org.apache.commons.lang3.ArrayUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -107,6 +108,42 @@ static Stream<Arguments> testDecodeSymbols() {
// @formatter:on
}
+ static Stream<Arguments> testKraftsInequality() {
+ // Each case provides code lengths that satisfy Kraft's inequality,
one extra code length that overflows the tree, the code length at which the
+ // violation surfaces, and the leaf node and available node counts at
that code length. The extra code length and the code length at which the
+ // violation surfaces differ when the extra code still fits at its own
depth but takes a node the longer codes need.
+ // @formatter:off
+ return Stream.of(
+ // Symbol 2: 0, symbols 1 and 3: 10 and 11
+ Arguments.of(new int[] {0, 2, 1, 2}, 2, 2, 3, 2),
+ // Symbols 0 and 1: 0 and 1
+ Arguments.of(new int[] {1, 1}, 1, 1, 3, 2),
+ // Symbols 0-2: 00, 01 and 10, symbols 3 and 4: 110 and 111
+ Arguments.of(new int[] {2, 2, 2, 3, 3}, 3, 3, 3, 2),
+ // Symbols 0-2: 00, 01 and 10, symbols 3-6: 1100, 1101, 1110
and 1111
+ Arguments.of(new int[] {2, 2, 2, 4, 4, 4, 4}, 4, 4, 5, 4),
+ // All 16 codes of length 4 are taken
+ Arguments.of(new int[] {4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4}, 4, 4, 17, 16),
+ // All 16 codes of length 4 are taken, so no node is left to
extend to length 5
+ Arguments.of(new int[] {4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4}, 5, 5, 1, 0),
+ // 15 codes of length 4 leave a single free node, split into
the two codes of length 5
+ Arguments.of(new int[] {4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 5, 5}, 5, 5, 3, 2),
+ // Same tree, now full at length 5, so no node is left to
extend to length 6
+ Arguments.of(new int[] {4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 5, 5}, 6, 6, 1, 0),
+ // Symbol 0: 0 takes half the tree, the other half holds 7
codes of length 4 plus the two codes of length 5
+ Arguments.of(new int[] {1, 4, 4, 4, 4, 4, 4, 4, 5, 5}, 5, 5,
3, 2),
+ // Leaf nodes at four different depths: 0, 10, then 3 codes of
length 4 and the two codes of length 5
+ Arguments.of(new int[] {1, 2, 4, 4, 4, 5, 5}, 5, 5, 3, 2),
+ // Symbol 0: 0, symbols 1-4: 100, 101, 110 and 111; the added
leaf node at length 2 used to be the parent of two of the codes of length 3
+ Arguments.of(new int[] {1, 3, 3, 3, 3}, 2, 3, 4, 2),
+ // Symbol 0: 0, symbols 1-8: 1000-1111; the added leaf node at
length 2 used to be the ancestor of four of the codes of length 4
+ Arguments.of(new int[] {1, 4, 4, 4, 4, 4, 4, 4, 4}, 2, 4, 8,
4),
+ // Same tree, where the added leaf node at length 3 used to be
the parent of two of the codes of length 4
+ Arguments.of(new int[] {1, 4, 4, 4, 4, 4, 4, 4, 4}, 3, 4, 8, 6)
+ );
+ // @formatter:on
+ }
+
private int decodeSymbol(final HuffmanDecoder decoder, final int... data)
throws IOException {
try (BitInputStream in = new BitInputStream(new
ByteArrayInputStream(AbstractTest.toByteArray(data)), ByteOrder.BIG_ENDIAN)) {
return decoder.decodeSymbol(in);
@@ -133,9 +170,10 @@ void testCodeLengthExceedingMaxCodeLength() throws
Exception {
void testCreateHuffmanDecodingTablesWithLargeAlphaSize() {
// Use a codeLengths array with length equal to MAX_ALPHA_SIZE (258)
to test array bounds.
final int[] codeLengths = new int[258];
- for (int i = 0; i < codeLengths.length; i++) {
- // Use all code lengths within valid range [1, 20]
- codeLengths[i] = (char) (i % 20 + 1);
+ // One symbol at the minimum length and the rest at the maximum length
so Kraft's inequality holds.
+ codeLengths[0] = 1;
+ for (int i = 1; i < codeLengths.length; i++) {
+ codeLengths[i] = 20;
}
final HuffmanDecoder decoder = assertDoesNotThrow(() -> new
HuffmanDecoder(codeLengths, 1, 20),
"HuffmanDecoder constructor should not throw for valid
codeLengths array of MAX_ALPHA_SIZE");
@@ -196,7 +234,7 @@ void testMinCodeLengthExceedingMaxCodeLength() {
}
@Test
- void testNoCodeLengths() throws Exception {
+ void testNoCodeLengths() {
final CompressorException e = assertThrows(CompressorException.class,
() -> new HuffmanDecoder(new int[0]),
"Expected CompressorException for empty code length list");
assertEquals("Empty code length list", e.getMessage());
@@ -234,4 +272,16 @@ void testSingleCodeLength() throws Exception {
"Expected CompressorException for invalid bitstream");
assertEquals("Invalid Huffman code: 2", e.getMessage());
}
+
+ @ParameterizedTest(name = "appending code length {1} to {0} overflows at
code length {2}")
+ @MethodSource
+ void testKraftsInequality(final int[] codeLengths, final int
extraCodeLength, final int expectedCodeLength, final int expectedLeafNodes,
+ final int expectedAvailableNodes) {
+ assertDoesNotThrow(() -> new HuffmanDecoder(codeLengths), "Code
lengths are expected to satisfy Kraft's inequality");
+ final int[] tooManyCodeLengths = ArrayUtils.add(codeLengths,
extraCodeLength);
+ final CompressorException e = assertThrows(CompressorException.class,
() -> new HuffmanDecoder(tooManyCodeLengths),
+ "Expected CompressorException for too many leaf nodes");
+ assertEquals(String.format("Tree contains too many leaf nodes for code
length %d: %d leaf nodes, but only %d nodes available", expectedCodeLength,
+ expectedLeafNodes, expectedAvailableNodes), e.getMessage());
+ }
}