fix(lib): harden Pack7 long decoding

Bound Pack7 long decoding to ten input bytes and fail closed on
truncated, over-width, and overflowing encodings.

Add boundary, truncation, exact-consumption, and deterministic
bounded-read regression tests.

Closes original audit finding H5.
This commit is contained in:
2026-07-30 22:33:46 +02:00
parent 2d35e61466
commit 87c59ab7fd
2 changed files with 191 additions and 10 deletions

View File

@@ -42,7 +42,10 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
@@ -143,6 +146,83 @@ public class UtilTest {
System.out.println("...ok");
}
@Test
public void packedLongBoundaryRoundTripsAndConsumesExactly() throws IOException {
System.out.println("packedLongBoundaryRoundTripsAndConsumesExactly");
List<Long> values = packedLongBoundaryValues();
for (long value : values) {
byte[] encoded = encodePackedLong(value);
assertEquals(canonicalPackedLongWidth(value), encoded.length, "Writer must use the canonical width");
byte[] framed = new byte[encoded.length + 2];
System.arraycopy(encoded, 0, framed, 0, encoded.length);
framed[encoded.length] = 0x55;
framed[encoded.length + 1] = (byte) 0xaa;
ByteArrayInputStream input = new ByteArrayInputStream(framed);
assertEquals(value, Util.readPack7L(input), "Packed long boundary value should round trip");
assertEquals(0x55, input.read(), "Decoder must leave the first sentinel unread");
assertEquals(0xaa, input.read(), "Decoder must leave the second sentinel unread");
}
ByteArrayInputStream nonCanonicalZero = new ByteArrayInputStream(new byte[] { 0x00, (byte) 0x80, 0x33 });
assertEquals(0L, Util.readPack7L(nonCanonicalZero), "Existing non-minimal encodings must remain readable");
assertEquals(0x33, nonCanonicalZero.read(), "Non-minimal decoding must consume exactly one value");
System.out.println("...boundary values=" + values.size());
System.out.println("...ok");
}
@Test
public void packedLongRejectsInitialAndContinuationEof() throws IOException {
System.out.println("packedLongRejectsInitialAndContinuationEof");
EOFException initial = assertThrows(EOFException.class,
() -> Util.readPack7L(new GuardedInputStream(new byte[0], 1)));
assertEquals("read packed long EOF", initial.getMessage());
byte[] maximumWidth = encodePackedLong(-1L);
for (int length = 1; length < maximumWidth.length; length++) {
byte[] truncated = new byte[length];
System.arraycopy(maximumWidth, 0, truncated, 0, length);
EOFException failure = assertThrows(EOFException.class,
() -> Util.readPack7L(new GuardedInputStream(truncated, truncated.length + 1)));
assertEquals("read packed long EOF", failure.getMessage());
}
byte[] maximumUnterminated = new byte[10];
IOException width = assertThrows(IOException.class,
() -> Util.readPack7L(new GuardedInputStream(maximumUnterminated, maximumUnterminated.length)));
assertEquals("packed long exceeds ten bytes", width.getMessage());
System.out.println("...truncated prefixes=10");
System.out.println("...ok");
}
@Test
public void packedLongRejectsWidthAndOverflowWithinTenReads() throws IOException {
System.out.println("packedLongRejectsWidthAndOverflowWithinTenReads");
byte[] overflow = new byte[10];
overflow[0] = 0x02;
overflow[overflow.length - 1] = (byte) 0xff;
GuardedInputStream overflowInput = new GuardedInputStream(overflow, 10);
IOException overflowFailure = assertThrows(IOException.class, () -> Util.readPack7L(overflowInput));
assertEquals("packed long exceeds 64 bits", overflowFailure.getMessage());
assertEquals(10, overflowInput.readCount(), "Overflow rejection must use the fixed read bound");
byte[] overWidth = new byte[11];
overWidth[overWidth.length - 1] = (byte) 0x80;
GuardedInputStream overWidthInput = new GuardedInputStream(overWidth, 10);
IOException widthFailure = assertThrows(IOException.class, () -> Util.readPack7L(overWidthInput));
assertEquals("packed long exceeds ten bytes", widthFailure.getMessage());
assertEquals(10, overWidthInput.readCount(), "Decoder must not consume an eleventh byte");
assertEquals(10, overWidthInput.position(), "The following byte must remain unread");
byte[] oldWraparound = new byte[] { 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f,
(byte) 0xff };
IOException wrapFailure = assertThrows(IOException.class,
() -> Util.readPack7L(new GuardedInputStream(oldWraparound, 10)));
assertEquals("packed long exceeds 64 bits", wrapFailure.getMessage());
System.out.println("...maximum decoder reads=10");
System.out.println("...ok");
}
@Test
public void testReadEOFHandling() throws IOException {
System.out.println("testReadEOFHandling");
@@ -163,4 +243,68 @@ public class UtilTest {
assertArrayEquals(data, result, "Large byte array should be preserved");
System.out.println("...ok");
}
private static List<Long> packedLongBoundaryValues() {
List<Long> values = new ArrayList<>();
values.add(Long.MIN_VALUE);
values.add(Long.MIN_VALUE + 1);
values.add(-1L);
values.add(0L);
values.add(1L);
for (int bits = 7; bits <= 56; bits += 7) {
long transition = 1L << bits;
values.add(transition - 1);
values.add(transition);
values.add(transition + 1);
}
values.add(Long.MAX_VALUE);
return values;
}
private static byte[] encodePackedLong(long value) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7L(output, value);
return output.toByteArray();
}
private static int canonicalPackedLongWidth(long value) {
int width = 1;
while ((value & ~0x7fL) != 0) {
width++;
value >>>= 7;
}
return width;
}
private static final class GuardedInputStream extends InputStream {
private final byte[] data;
private final int maximumReads;
private int position;
private int readCount;
private GuardedInputStream(byte[] data, int maximumReads) {
this.data = data.clone();
this.maximumReads = maximumReads;
}
@Override
public int read() {
if (readCount >= maximumReads) {
throw new AssertionError("decoder exceeded the permitted read bound");
}
readCount++;
if (position >= data.length) {
return -1;
}
return data[position++] & 0xff;
}
private int position() {
return position;
}
private int readCount() {
return readCount;
}
}
}