Skip to content

Commit 12d7466

Browse files
committed
fix(reader): harden RunEnd/Constant/Zoned/Pco against malformed input
Part of TODO.md's "Per-encoding adversarial tests" security item (CLAUDE.md §Security contract): a malformed file must always throw VortexException, never a raw JDK exception. - RunEndEncodingDecoder: reject negative num_runs and zero runs paired with a non-empty row count — previously decoded "successfully" into a lazy array backed by an empty ends/values child, then threw a raw IndexOutOfBoundsException/ArithmeticException on first read. - ConstantEncodingDecoder: reject a Decimal-typed constant whose scalar oneof doesn't carry bytes_value (raw NullPointerException reading its length). - ScanIterator.decodeZoneTable: bound the zone-map table's declared row count (an unvalidated layout field, deliberately decoupled from the data layout's chunk count) before it sizes an ArrayList — a negative value threw a raw IllegalArgumentException and a value just over Integer.MAX_VALUE wrapped negative on the int cast. - PcoTansDecoder.build: size the degenerate (zero-bin) decode table to tableSize instead of a fixed 1-state table — a page's initial ANS state indices are read with ansSizeLog bits regardless of bin count, so a corrupt file pairing zero bins with a nonzero ansSizeLog indexed a stale 1-entry array out of bounds. - PcoEncodingDecoder: reject a bin offsetBits > 64 (wider than any latent); validate that declared per-page value counts are non-negative and sum to the expected valid row count before allocating/writing latent buffers sized or offset by them. TODO.md: mark RunEnd, Constant, Zoned, and Pco done — all eleven per-encoding gotchas now closed.
1 parent e05a92b commit 12d7466

11 files changed

Lines changed: 396 additions & 10 deletions

TODO.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,7 @@ known gap, a contract audit, or supporting infra.
3838
Each encoding's `decode(DecodeContext)` should be exercised against crafted metadata that
3939
decodes but disagrees with the buffer payload. `bufferIndices[i] >= ctx.bufferCount()` (and the
4040
equivalent child-index check) is centralized in `DecodeContext.buffer(i)`/`decodeChild(i)`.
41-
VarBin, Dict, Bitpacked, ALP, Sparse, Chunked, and Struct are done — remaining gotchas:
42-
43-
- [ ] **RLE / RunEnd**: `run_ends` non-monotonic; last `run_end``row_count`.
44-
- [ ] **Constant**: protobuf scalar value missing or type-mismatched against declared `DType`.
45-
- [ ] **Zoned**: zone-map min > max; zone count ≠ child chunk count.
46-
- [ ] **Pco**: `bits_per_offset > 64`; `bin_count == 0` with non-empty page; per-page
47-
`n` greater than `DEFAULT_MAX_PAGE_N`; ANS state values inconsistent with weight table.
41+
VarBin, Dict, Bitpacked, ALP, Sparse, Chunked, Struct, RunEnd, Constant, Zoned, and Pco are done.
4842

4943
### Resource caps
5044

reader/src/main/java/io/github/dfa1/vortex/reader/ScanIterator.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,15 @@ private List<ArrayStats> decodeZoneTable(ColumnName column) {
475475
return null;
476476
}
477477
long nZones = statsFlat.rowCount();
478+
// statsFlat.rowCount() is an unvalidated field straight from the layout FlatBuffer
479+
// (PostscriptParser never bounds it — zone count is deliberately decoupled from the
480+
// data layout's chunk count, see this method's Javadoc). Below it sizes an ArrayList
481+
// and drives a per-zone loop via an `(int) nZones` cast: a negative value throws a raw
482+
// IllegalArgumentException from the ArrayList constructor instead of degrading to "no
483+
// zone map" like every other unusable shape this method already falls back on.
484+
if (nZones < 0 || nZones > Integer.MAX_VALUE) {
485+
return null;
486+
}
478487
SegmentSpec spec = file.footer().segmentSpecs().get(segIdx);
479488
try (Arena tableArena = Arena.ofConfined()) {
480489
Array decoded = file.decodeSegment(spec, statsDtype, nZones, tableArena);

reader/src/main/java/io/github/dfa1/vortex/reader/decode/ConstantEncodingDecoder.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ private static Array constantPrimitive(DType outDtype, PType ptype, ProtoScalarV
114114

115115
private static Array decodeDecimal(DType dtype, ProtoScalarValue scalar, long n) {
116116
byte[] elemBytes = scalar.bytes_value();
117+
if (elemBytes == null) {
118+
// A scalar whose oneof tag doesn't match the declared Decimal dtype (e.g. only
119+
// int64_value set) leaves bytes_value() null; without this guard the length read
120+
// below is a raw NullPointerException instead of a VortexException (ADR 0003).
121+
throw new VortexException(EncodingId.VORTEX_CONSTANT,
122+
"constant decimal scalar missing bytes_value");
123+
}
117124
int elemLen = elemBytes.length;
118125
// Decode the single scalar value via LazyDecimalArray (reuses its LE byte-order logic),
119126
// then wrap in a constant array — O(1) allocation regardless of row count.

reader/src/main/java/io/github/dfa1/vortex/reader/decode/PcoEncodingDecoder.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.github.dfa1.vortex.core.io.PTypeIO;
88
import io.github.dfa1.vortex.core.proto.ProtoPcoChunkInfo;
99
import io.github.dfa1.vortex.core.proto.ProtoPcoMetadata;
10+
import io.github.dfa1.vortex.core.proto.ProtoPcoPageInfo;
1011
import io.github.dfa1.vortex.reader.array.Array;
1112
import io.github.dfa1.vortex.reader.array.BoolArray;
1213
import io.github.dfa1.vortex.reader.array.MaskedArray;
@@ -67,6 +68,28 @@ public Array decode(DecodeContext ctx) {
6768
}
6869
}
6970

71+
// Pages declare their own value counts (ProtoPcoPageInfo.n_values), independent of
72+
// validCount. A crafted file can pair a huge or negative per-page count with a small
73+
// rowCount: without this check, a negative count silently no-ops its loop while a
74+
// desynced total either writes past rawLatents/compactOut (raw IndexOutOfBounds) or
75+
// sizes rawAdjs from an attacker-controlled chunkN unrelated to any real buffer
76+
// (OutOfMemoryError). Validating the total up front keeps every per-page/per-chunk
77+
// access below implicitly bounded by validCount.
78+
long totalPageValues = 0L;
79+
for (ProtoPcoChunkInfo chunkInfo : meta.chunks()) {
80+
for (ProtoPcoPageInfo page : chunkInfo.pages()) {
81+
if (page.n_values() < 0) {
82+
throw new VortexException(EncodingId.VORTEX_PCO,
83+
"pco page n_values " + page.n_values() + " is negative");
84+
}
85+
totalPageValues += page.n_values();
86+
}
87+
}
88+
if (totalPageValues != validCount) {
89+
throw new VortexException(EncodingId.VORTEX_PCO,
90+
"pco total page values " + totalPageValues + " != expected valid row count " + validCount);
91+
}
92+
7093
MemorySegment rawLatents = ctx.arena().allocate(validCount * Long.BYTES);
7194

7295
int nChunks = meta.chunks().size();
@@ -727,6 +750,14 @@ private static PcoBin[] readBins(LeBitReader r, int nBins, int ansSizeLog, int d
727750
int weight = (int) r.readBits(ansSizeLog) + 1;
728751
long lower = r.readBits(dtypeSize);
729752
int offsetBits = (int) r.readBits(offsetBitsWidth);
753+
if (offsetBits > 64) {
754+
// offsetBitsWidth is 5/6/7 bits wide (max value 31/63/127), wider than the
755+
// 64-bit latent an offset can ever legally span; a page later reads this many
756+
// bits per value via LeBitReader#readBits(int), whose own <=64 contract this
757+
// would otherwise violate.
758+
throw new VortexException(EncodingId.VORTEX_PCO,
759+
"pco bin offsetBits " + offsetBits + " exceeds max 64");
760+
}
730761
bins[b] = new PcoBin(weight, lower, offsetBits);
731762
}
732763
return bins;

reader/src/main/java/io/github/dfa1/vortex/reader/decode/PcoTansDecoder.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,17 @@ private PcoTansDecoder(int[] nextStateIdxBase, int[] bitsToRead,
3333
///
3434
/// Port of `Spec::from_weights` + `Decoder::new` from pcodec.
3535
public static PcoTansDecoder build(int ansSizeLog, PcoBin[] bins) {
36+
int tableSize = 1 << ansSizeLog;
3637
if (bins.length == 0) {
37-
// Degenerate: no bins → 1-state table, all offsets zero.
38-
return new PcoTansDecoder(new int[]{0}, new int[]{0}, new int[]{0}, new long[]{0L});
38+
// Degenerate: no bins → every state decodes to offset zero. Sized to tableSize
39+
// (not a fixed 1-state table): the initial ANS state indices a page carries are
40+
// read with ansSizeLog bits (so any value in [0, tableSize) is possible) before
41+
// this decoder is consulted — a corrupt file pairing zero bins with a nonzero
42+
// ansSizeLog previously indexed a real 1-entry table out of bounds, a raw
43+
// ArrayIndexOutOfBoundsException instead of a VortexException (ADR 0003).
44+
return new PcoTansDecoder(new int[tableSize], new int[tableSize], new int[tableSize], new long[tableSize]);
3945
}
4046

41-
int tableSize = 1 << ansSizeLog;
4247
int[] weights = new int[bins.length];
4348
for (int i = 0; i < bins.length; i++) {
4449
weights[i] = bins[i].weight();

reader/src/main/java/io/github/dfa1/vortex/reader/decode/RunEndEncodingDecoder.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ public Array decode(DecodeContext ctx) {
5252
long offset = meta.offset();
5353

5454
long n = ctx.rowCount();
55+
if (numRuns < 0) {
56+
throw new VortexException(EncodingId.VORTEX_RUNEND, "runend: negative num_runs " + numRuns);
57+
}
58+
if (numRuns == 0 && n > 0) {
59+
// Zero runs cover no rows — a crafted file pairing that with a non-empty row
60+
// count previously decoded "successfully" into a LazyRunEndXxxArray backed by
61+
// an empty ends/values child, then threw a raw IndexOutOfBoundsException (or
62+
// ArithmeticException via the % elementCount broadcast path) on first read
63+
// instead of failing here as a VortexException.
64+
throw new VortexException(EncodingId.VORTEX_RUNEND,
65+
"runend: zero runs cannot cover " + n + " row(s)");
66+
}
5567
DType endsDtype = new DType.Primitive(endsPtype, false);
5668
Array endsArr = ctx.decodeChild(0, endsDtype, numRuns);
5769
Array endsData = endsArr instanceof MaskedArray m ? m.inner() : endsArr;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package io.github.dfa1.vortex.reader;
2+
3+
import io.github.dfa1.vortex.core.model.ColumnName;
4+
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.core.model.LayoutId;
6+
import io.github.dfa1.vortex.reader.layout.Layout;
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.api.extension.ExtendWith;
9+
import org.junit.jupiter.params.ParameterizedTest;
10+
import org.junit.jupiter.params.provider.ValueSource;
11+
import org.mockito.Mock;
12+
import org.mockito.junit.jupiter.MockitoExtension;
13+
14+
import java.lang.foreign.Arena;
15+
import java.lang.foreign.MemorySegment;
16+
import java.lang.foreign.ValueLayout;
17+
import java.util.List;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.mockito.BDDMockito.given;
21+
22+
/// A `vortex.stats` (zoned) layout's zone-map table row count is its own layout metadata field
23+
/// (`statsFlat.rowCount()`), never bounds-checked at parse time or cross-checked against the
24+
/// data layout's actual chunk count (see [ScanIterator#columnZoneStats] Javadoc — the two are
25+
/// deliberately decoupled). [ScanIterator] previously cast that attacker-controlled row count
26+
/// straight to `int` to size an `ArrayList`: a negative value threw a raw
27+
/// `IllegalArgumentException` and a value just over `Integer.MAX_VALUE` wrapped to negative on
28+
/// the cast, both instead of the documented "fall back to per-chunk stats" behavior.
29+
@ExtendWith(MockitoExtension.class)
30+
class ScanIteratorZoneCountAdversarialTest {
31+
32+
private static final ColumnName COLUMN = ColumnName.of("v");
33+
private static final DType.Struct SCHEMA = new DType.Struct(List.of(COLUMN), List.of(DType.I64), false);
34+
35+
@Mock
36+
private VortexHandle file;
37+
38+
@ParameterizedTest
39+
@ValueSource(longs = {-1L, Long.MIN_VALUE, ((long) Integer.MAX_VALUE) + 1L, Long.MAX_VALUE})
40+
void corruptZoneCount_fallsBackInsteadOfCrashing(long corruptZoneCount) {
41+
// Given — a one-chunk file whose zone-map table declares a corrupt row count
42+
Layout root = rootLayout(corruptZoneCount);
43+
Footer footer = new Footer(List.of(), List.of(),
44+
List.of(new SegmentSpec(0, 0, (byte) 0, CompressionScheme.NONE),
45+
new SegmentSpec(0, 0, (byte) 0, CompressionScheme.NONE)),
46+
List.of());
47+
given(file.dtype()).willReturn(SCHEMA);
48+
given(file.layout()).willReturn(root);
49+
given(file.footer()).willReturn(footer);
50+
51+
// When
52+
List<ArrayStats> result;
53+
try (ScanIterator sut = new ScanIterator(file, ScanOptions.columns("v"))) {
54+
result = sut.columnZoneStats("v");
55+
}
56+
57+
// Then — degrades to the per-chunk fallback (one empty entry per chunk), no raw exception
58+
assertThat(result).hasSize(1);
59+
assertThat(result.getFirst()).isEqualTo(ArrayStats.empty());
60+
}
61+
62+
@Test
63+
void plausibleZoneCount_isNotRejected() {
64+
// Given — a small, legitimate-looking zone count on an otherwise-corrupt (headerless)
65+
// stats segment, which still degrades gracefully once decoding is attempted
66+
Layout root = rootLayout(1L);
67+
Footer footer = new Footer(List.of(), List.of(),
68+
List.of(new SegmentSpec(0, 0, (byte) 0, CompressionScheme.NONE),
69+
new SegmentSpec(0, 0, (byte) 0, CompressionScheme.NONE)),
70+
List.of());
71+
given(file.dtype()).willReturn(SCHEMA);
72+
given(file.layout()).willReturn(root);
73+
given(file.footer()).willReturn(footer);
74+
75+
// When
76+
List<ArrayStats> result;
77+
try (ScanIterator sut = new ScanIterator(file, ScanOptions.columns("v"))) {
78+
result = sut.columnZoneStats("v");
79+
}
80+
81+
// Then — the guard only rejects implausible counts; this one reaches the normal decode
82+
// path (which itself falls back gracefully on the segment's missing content)
83+
assertThat(result).hasSize(1);
84+
}
85+
86+
/// Builds `Struct(v) -> Zoned[Flat(data, empty segment 0), Flat(stats, rowCount=zoneCount,
87+
/// segment 1)]`. The data flat's zero-length segment makes the per-chunk fallback resolve to
88+
/// [ArrayStats#empty()] without needing real FlatBuffer bytes.
89+
private static Layout rootLayout(long zoneCount) {
90+
Layout dataFlat = new Layout(LayoutId.FLAT, 5, null, List.of(), List.of(0));
91+
Layout statsFlat = new Layout(LayoutId.FLAT, zoneCount, minStatBitset(), List.of(), List.of(1));
92+
Layout zoned = new Layout(LayoutId.STATS, 5, null, List.of(dataFlat, statsFlat), List.of());
93+
return new Layout(LayoutId.STRUCT, 5, null, List.of(zoned), List.of());
94+
}
95+
96+
/// `vortex.stats` metadata: 4-byte zone length (unused here) + a bitset with the `MIN` bit
97+
/// (ordinal 4) set, so [io.github.dfa1.vortex.reader.layout.ZonedStatsSchema#statsTableDtype]
98+
/// resolves a non-empty schema and the code under test proceeds past its early-return guards.
99+
private static MemorySegment minStatBitset() {
100+
MemorySegment seg = Arena.ofAuto().allocate(5);
101+
seg.set(ValueLayout.JAVA_BYTE, 4, (byte) 0x10);
102+
return seg;
103+
}
104+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.github.dfa1.vortex.reader.decode;
2+
3+
import io.github.dfa1.vortex.core.error.VortexException;
4+
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.core.model.EncodingId;
6+
import io.github.dfa1.vortex.core.proto.ProtoScalarValue;
7+
import io.github.dfa1.vortex.reader.ReadRegistry;
8+
import io.github.dfa1.vortex.reader.array.Array;
9+
import io.github.dfa1.vortex.reader.array.LongArray;
10+
import org.junit.jupiter.api.Test;
11+
12+
import java.lang.foreign.Arena;
13+
import java.lang.foreign.MemorySegment;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
17+
18+
class ConstantEncodingDecoderTest {
19+
20+
private static final ConstantEncodingDecoder SUT = new ConstantEncodingDecoder();
21+
22+
@Test
23+
void encodingId_isVortexConstant() {
24+
// Given / When / Then
25+
assertThat(SUT.encodingId()).isEqualTo(EncodingId.VORTEX_CONSTANT);
26+
}
27+
28+
@Test
29+
void primitiveScalar_missingAllValueFields_decodesAsZero() {
30+
// Given — a scalar with every oneof field null (no tag matched the declared I64
31+
// dtype); scalarToRawBits() has an explicit fallback for this, so it must not crash.
32+
ProtoScalarValue scalar = new ProtoScalarValue(null, null, null, null, null, null, null, null, null, null, null);
33+
34+
// When
35+
Array result = decode(scalar, DType.I64, 3);
36+
37+
// Then
38+
LongArray longs = (LongArray) result;
39+
assertThat(longs.getLong(0)).isZero();
40+
assertThat(longs.getLong(2)).isZero();
41+
}
42+
43+
/// A scalar whose oneof tag doesn't match the declared Decimal dtype (e.g. only
44+
/// int64_value set, `bytes_value` absent) previously threw a raw NullPointerException
45+
/// reading `bytes_value().length` instead of a [VortexException] (ADR 0003).
46+
@Test
47+
void decimalScalar_missingBytesValue_throwsVortexException() {
48+
// Given — int64_value set, bytes_value absent, for a Decimal-typed constant
49+
ProtoScalarValue scalar = new ProtoScalarValue(null, null, 42L, null, null, null, null, null, null, null, null);
50+
DType decimalDtype = new DType.Decimal((byte) 10, (byte) 2, false);
51+
52+
// When / Then
53+
assertThatThrownBy(() -> decode(scalar, decimalDtype, 1))
54+
.isInstanceOf(VortexException.class)
55+
.hasMessageContaining("bytes_value");
56+
}
57+
58+
@Test
59+
void decimalScalar_withBytesValue_decodes() {
60+
// Given — a 4-byte little-endian two's-complement decimal, scale 2 → 12345 / 100
61+
ProtoScalarValue scalar = new ProtoScalarValue(
62+
null, null, null, null, null, null, null,
63+
new byte[]{(byte) 0x39, (byte) 0x30, (byte) 0x00, (byte) 0x00}, null, null, null);
64+
DType decimalDtype = new DType.Decimal((byte) 9, (byte) 2, false);
65+
66+
// When
67+
Array result = decode(scalar, decimalDtype, 2);
68+
69+
// Then
70+
assertThat(result.length()).isEqualTo(2);
71+
}
72+
73+
private static Array decode(ProtoScalarValue scalar, DType dtype, long n) {
74+
MemorySegment scalarBuf = MemorySegment.ofArray(scalar.encode());
75+
ArrayNode node = new ArrayNode(EncodingId.VORTEX_CONSTANT, null, new ArrayNode[0], new int[]{0});
76+
DecodeContext ctx = new DecodeContext(node, dtype, n, new MemorySegment[]{scalarBuf},
77+
ReadRegistry.empty(), Arena.ofAuto());
78+
return SUT.decode(ctx);
79+
}
80+
}

reader/src/test/java/io/github/dfa1/vortex/reader/decode/PcoEncodingDecoderTest.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ private static MemorySegment chunkMetaConv1(int quantization, long biasLatent,
146146
return segmentOf(buf);
147147
}
148148

149+
/// Packs `values[i]` into `widths[i]` bits, LSB-first per field then concatenated — the
150+
/// same layout [LeBitReader#readBits(int)] consumes.
151+
private static byte[] packBitsLsbFirst(int[] widths, long[] values) {
152+
java.util.BitSet bits = new java.util.BitSet();
153+
int pos = 0;
154+
for (int f = 0; f < widths.length; f++) {
155+
for (int i = 0; i < widths[f]; i++) {
156+
if (((values[f] >>> i) & 1L) != 0L) {
157+
bits.set(pos);
158+
}
159+
pos++;
160+
}
161+
}
162+
byte[] buf = new byte[Math.max((pos + 7) / 8, 1)];
163+
for (int i = 0; i < pos; i++) {
164+
if (bits.get(i)) {
165+
buf[i / 8] |= (byte) (1 << (i % 8));
166+
}
167+
}
168+
return buf;
169+
}
170+
149171
private static MemorySegment chunkMetaLookback() {
150172
return segmentOf((byte) 0x20, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00);
151173
}
@@ -509,5 +531,45 @@ void conv1Delta_with64BitDtype_throwsVortexException(PType ptype) {
509531
.isInstanceOf(VortexException.class)
510532
.hasMessageContaining("Conv1");
511533
}
534+
535+
@Test
536+
void binOffsetBitsExceeds64_throwsVortexException() {
537+
// Given — mode=Classic(0), delta=NoOp(0), ansSizeLog=0, nBins=1, one bin whose
538+
// offsetBits (100) exceeds the 64-bit latent it would be read into.
539+
byte[] chunkMeta = packBitsLsbFirst(
540+
new int[]{4, 4, 4, 15, 64, 7},
541+
new long[]{0, 0, 0, 1, 0, 100});
542+
DecodeContext ctx = ctxWith(metaWithOneChunk(1), DType.U64, 1,
543+
new MemorySegment[]{segmentOf(chunkMeta), segmentOf((byte) 0x00)});
544+
545+
// When / Then
546+
assertThatThrownBy(() -> SUT.decode(ctx))
547+
.isInstanceOf(VortexException.class)
548+
.hasMessageContaining("offsetBits");
549+
}
550+
551+
@Test
552+
void pageValuesTotalMismatchesRowCount_throwsVortexException() {
553+
// Given — one page declares 5 values but the context row count is 3
554+
DecodeContext ctx = ctxWith(metaWithOneChunk(5), DType.U64, 3,
555+
new MemorySegment[]{segmentOf((byte) 0x00), segmentOf((byte) 0x00)});
556+
557+
// When / Then
558+
assertThatThrownBy(() -> SUT.decode(ctx))
559+
.isInstanceOf(VortexException.class)
560+
.hasMessageContaining("total page values");
561+
}
562+
563+
@Test
564+
void negativePageNValues_throwsVortexException() {
565+
// Given — a page whose n_values decodes to -1 (a valid varint32 on the wire)
566+
DecodeContext ctx = ctxWith(metaWithOneChunk(-1), DType.U64, 0,
567+
new MemorySegment[]{segmentOf((byte) 0x00), segmentOf((byte) 0x00)});
568+
569+
// When / Then
570+
assertThatThrownBy(() -> SUT.decode(ctx))
571+
.isInstanceOf(VortexException.class)
572+
.hasMessageContaining("negative");
573+
}
512574
}
513575
}

0 commit comments

Comments
 (0)