Skip to content

Commit 45b10fe

Browse files
dfa1claude
andcommitted
test(reader): close depth-guard mutation-coverage gaps
PIT on the reader (-P pitest) flagged six survivors in the depth/bounds guards added by the hardening work. Each marks a real coverage hole, not an arbitrary edge: - convertDType recursion was only exercised through the List arm, so the depth+1 increment on the Struct / FixedSizeList / Extension arms could be mutated away undetected. Parameterize the dtype depth-bomb over all four NestKind variants. - Pin the convertDType and convertArrayNode depth caps at their exact boundary (limit parses, limit+1 throws), mirroring the existing layout-depth tests. - Pin parseBlobs treating a present-but-empty dtype blob as a null dtype. Reader PIT is now 0 survivors (112 killed, 1 timed-out, 100% test strength). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 111cb6b commit 45b10fe

4 files changed

Lines changed: 153 additions & 26 deletions

File tree

reader/src/test/java/io/github/dfa1/vortex/reader/ArrayNodeDepthBombSecurityTest.java

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,62 @@
1414

1515
import static org.assertj.core.api.Assertions.assertThatThrownBy;
1616

17-
/// Adversarial test for the encoded-array-tree recursion in
17+
/// Adversarial tests for the encoded-array-tree recursion in
1818
/// [FlatSegmentDecoder]'s `convertArrayNode`.
1919
///
2020
/// The decoder walks the array node tree recursively (validity, patches, run-ends, dictionary
2121
/// codes/values, …). Without the [FlatSegmentDecoder#MAX_ARRAY_TREE_DEPTH] cap a crafted segment
2222
/// with thousands of nested children produces a [StackOverflowError] — an `Error` that escapes the
23-
/// "malformed input must surface as [VortexException]" contract (ADR 0003). This pins that contract.
23+
/// "malformed input must surface as [VortexException]" contract (ADR 0003).
24+
///
25+
/// The two cases bracket the exact `depth > MAX_ARRAY_TREE_DEPTH` boundary: a tree whose deepest
26+
/// node sits at exactly the limit must clear the guard (and then fail later for an unrelated reason
27+
/// — no decoder), while one node deeper must be rejected by the depth guard itself.
2428
class ArrayNodeDepthBombSecurityTest {
2529

2630
private static final DType DTYPE = DType.I32;
2731

2832
private final FlatSegmentDecoder sut = new FlatSegmentDecoder(ReadRegistry.empty());
2933

3034
@Test
31-
void deeplyNestedArrayTree_throwsVortexException() {
35+
void arrayTreeAtDepthLimit_clearsGuard() {
36+
try (Arena arena = Arena.ofConfined()) {
37+
// Given — deepest node at exactly MAX_ARRAY_TREE_DEPTH: convertArrayNode is entered with
38+
// depth == limit there, and `limit > limit` is false. The walk completes and only then
39+
// fails because the empty registry has no decoder. Kills `depth >` relaxed to `>=`,
40+
// which would wrongly reject this legal max-depth tree with the depth message.
41+
byte[] fb = deeplyNestedArrayFlatBuffer(FlatSegmentDecoder.MAX_ARRAY_TREE_DEPTH);
42+
MemorySegment seg = wrapAsSegment(fb, arena);
43+
44+
// When / Then
45+
assertThatThrownBy(() -> sut.decode(seg, List.of("vortex.flat"), DTYPE, 1, arena))
46+
.isInstanceOf(VortexException.class)
47+
.hasMessageContaining("no decoder");
48+
}
49+
}
50+
51+
@Test
52+
void arrayTreeOneOverDepthLimit_throwsVortexException() {
3253
try (Arena arena = Arena.ofConfined()) {
33-
// Given — a flat segment whose FbsArray root nests 65536 levels of single-child nodes.
34-
// Real encodings nest only a handful of levels; 65536 reliably blows the JVM stack on
35-
// the recursive convertArrayNode walk if the depth cap is removed.
36-
byte[] fb = deeplyNestedArrayFlatBuffer(65536);
37-
MemorySegment seg = arena.allocate(fb.length + 4L);
38-
MemorySegment.copy(MemorySegment.ofArray(fb), 0, seg, 0, fb.length);
39-
seg.set(PTypeIO.LE_INT, fb.length, fb.length);
54+
// Given — one level deeper: the deepest node reaches limit + 1, tripping the guard
55+
// before any StackOverflowError can escape.
56+
byte[] fb = deeplyNestedArrayFlatBuffer(FlatSegmentDecoder.MAX_ARRAY_TREE_DEPTH + 1);
57+
MemorySegment seg = wrapAsSegment(fb, arena);
4058

4159
// When / Then — must surface as VortexException, not StackOverflowError
4260
assertThatThrownBy(() -> sut.decode(seg, List.of("vortex.flat"), DTYPE, 1, arena))
43-
.isInstanceOf(VortexException.class);
61+
.isInstanceOf(VortexException.class)
62+
.hasMessageContaining("depth");
4463
}
4564
}
4665

66+
private static MemorySegment wrapAsSegment(byte[] fb, Arena arena) {
67+
MemorySegment seg = arena.allocate(fb.length + 4L);
68+
MemorySegment.copy(MemorySegment.ofArray(fb), 0, seg, 0, fb.length);
69+
seg.set(PTypeIO.LE_INT, fb.length, fb.length);
70+
return seg;
71+
}
72+
4773
/// Builds a minimal `FbsArray` whose root node has `depth` levels of single-child nesting,
4874
/// each level a buffer-less node referencing encoding index 0.
4975
private static byte[] deeplyNestedArrayFlatBuffer(int depth) {

reader/src/test/java/io/github/dfa1/vortex/reader/DTypeDepthBombSecurityTest.java

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22

33
import io.github.dfa1.vortex.core.error.VortexException;
44
import io.github.dfa1.vortex.core.io.VortexFormat;
5-
import org.junit.jupiter.api.Test;
5+
import io.github.dfa1.vortex.reader.MalformedFiles.NestKind;
66
import org.junit.jupiter.api.io.TempDir;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.EnumSource;
79

810
import java.io.OutputStream;
911
import java.nio.ByteBuffer;
1012
import java.nio.ByteOrder;
1113
import java.nio.file.Files;
1214
import java.nio.file.Path;
1315

14-
import static io.github.dfa1.vortex.reader.MalformedFiles.buildDeeplyNestedListDtype;
16+
import static io.github.dfa1.vortex.reader.MalformedFiles.buildDeeplyNestedDtype;
1517
import static io.github.dfa1.vortex.reader.MalformedFiles.buildFlatLayout;
1618
import static io.github.dfa1.vortex.reader.MalformedFiles.buildFooter;
1719
import static io.github.dfa1.vortex.reader.MalformedFiles.buildPostscript;
@@ -29,25 +31,28 @@ class DTypeDepthBombSecurityTest {
2931

3032
private static final ReadRegistry REGISTRY = ReadRegistry.empty();
3133

32-
@Test
33-
void deeplyNestedDtype_throwsVortexException(@TempDir Path tmp) throws Exception {
34-
// Given — a file whose DType nests 65536 levels of List. Real schemas nest a handful of
35-
// levels; 65536 reliably blows the JVM stack on the recursive convertDType walk.
36-
Path file = buildDeeplyNestedDtypeFile(tmp, 65536);
34+
@ParameterizedTest(name = "nested via {0}")
35+
@EnumSource(NestKind.class)
36+
void deeplyNestedDtype_throwsVortexException(NestKind kind, @TempDir Path tmp) throws Exception {
37+
// Given — a file whose DType nests 65536 levels of `kind`. Real schemas nest a handful of
38+
// levels; 65536 reliably blows the JVM stack on the recursive convertDType walk. Each kind
39+
// drives a different recursion arm (List/Struct/FixedSizeList/Extension), so all four must
40+
// increment the depth counter for the MAX_DTYPE_DEPTH guard to bound them.
41+
Path file = buildDeeplyNestedDtypeFile(tmp, 65536, kind);
3742

3843
// When / Then — must surface as VortexException, not StackOverflowError
3944
assertThatThrownBy(() -> VortexReader.open(file, REGISTRY))
4045
.isInstanceOf(VortexException.class);
4146
}
4247

43-
private static Path buildDeeplyNestedDtypeFile(Path dir, int depth) throws Exception {
48+
private static Path buildDeeplyNestedDtypeFile(Path dir, int depth, NestKind kind) throws Exception {
4449
byte[] body = new byte[8]; // unused placeholder
4550
ByteBuffer footerBuf = buildFooter(
4651
new String[]{"vortex.primitive"},
4752
new String[]{"vortex.flat"},
4853
new long[]{0L},
4954
new long[]{(long) body.length});
50-
ByteBuffer dtypeBuf = buildDeeplyNestedListDtype(depth);
55+
ByteBuffer dtypeBuf = buildDeeplyNestedDtype(depth, kind);
5156
ByteBuffer layoutBuf = buildFlatLayout(0, 1L, 0);
5257

5358
long footerOff = body.length;

reader/src/test/java/io/github/dfa1/vortex/reader/MalformedFiles.java

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import io.github.dfa1.vortex.core.fbs.FbsFooter;
66
import io.github.dfa1.vortex.core.fbs.FbsLayout;
77
import io.github.dfa1.vortex.core.fbs.FbsLayoutSpec;
8+
import io.github.dfa1.vortex.core.fbs.FbsExtension;
9+
import io.github.dfa1.vortex.core.fbs.FbsFixedSizeList;
810
import io.github.dfa1.vortex.core.fbs.FbsList;
11+
import io.github.dfa1.vortex.core.fbs.FbsStruct_;
912
import io.github.dfa1.vortex.core.fbs.FbsPostscript;
1013
import io.github.dfa1.vortex.core.fbs.FbsPostscriptSegment;
1114
import io.github.dfa1.vortex.core.fbs.FbsPrimitive;
@@ -35,18 +38,50 @@ static ByteBuffer buildI64Dtype() {
3538
return slice(fbb);
3639
}
3740

38-
/// Builds a DType blob nesting `depth` levels of `List` around an I64 primitive leaf.
41+
/// The recursive DType variant used to wrap each level of a depth-bomb dtype. Each constant
42+
/// exercises a different recursion arm of `PostscriptParser.convertDType`.
43+
enum NestKind {
44+
/// Nest via `List` element type.
45+
LIST,
46+
/// Nest via single-field `Struct` field type.
47+
STRUCT,
48+
/// Nest via `FixedSizeList` element type.
49+
FIXED_SIZE_LIST,
50+
/// Nest via `Extension` storage type.
51+
EXTENSION
52+
}
53+
54+
/// Builds a DType blob nesting `depth` levels of `kind` around an I64 primitive leaf.
3955
///
40-
/// @param depth number of nested `List` wrappers around the leaf
56+
/// @param depth number of nested wrappers around the leaf
57+
/// @param kind the recursive DType variant to wrap each level with
4158
/// @return the finished DType FlatBuffer
42-
static ByteBuffer buildDeeplyNestedListDtype(int depth) {
43-
var fbb = new FbsBuilder(depth * 32);
59+
static ByteBuffer buildDeeplyNestedDtype(int depth, NestKind kind) {
60+
var fbb = new FbsBuilder(depth * 64);
4461
// Leaf first; FlatBuffer requires children be finished before parents.
4562
int prim = FbsPrimitive.createFbsPrimitive(fbb, io.github.dfa1.vortex.core.fbs.FbsPType.I64, false);
4663
int current = io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsPrimitive, prim);
4764
for (int i = 0; i < depth; i++) {
48-
int list = FbsList.createFbsList(fbb, current, false);
49-
current = io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsList, list);
65+
current = switch (kind) {
66+
case LIST -> {
67+
int list = FbsList.createFbsList(fbb, current, false);
68+
yield io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsList, list);
69+
}
70+
case STRUCT -> {
71+
int names = FbsStruct_.createNamesVector(fbb, new int[]{fbb.createString("f")});
72+
int dtypes = FbsStruct_.createDtypesVector(fbb, new int[]{current});
73+
int struct = FbsStruct_.createFbsStruct_(fbb, names, dtypes, false);
74+
yield io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsStruct_, struct);
75+
}
76+
case FIXED_SIZE_LIST -> {
77+
int fsl = FbsFixedSizeList.createFbsFixedSizeList(fbb, current, 1L, false);
78+
yield io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsFixedSizeList, fsl);
79+
}
80+
case EXTENSION -> {
81+
int ext = FbsExtension.createFbsExtension(fbb, fbb.createString("x"), current, 0);
82+
yield io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsExtension, ext);
83+
}
84+
};
5085
}
5186
io.github.dfa1.vortex.core.fbs.FbsDType.finishFbsDTypeBuffer(fbb, current);
5287
return slice(fbb);

reader/src/test/java/io/github/dfa1/vortex/reader/PostscriptParserParseBlobsBoundsTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import io.github.dfa1.vortex.core.fbs.FbsFooter;
1111
import io.github.dfa1.vortex.core.fbs.FbsLayout;
1212
import io.github.dfa1.vortex.core.fbs.FbsLayoutSpec;
13+
import io.github.dfa1.vortex.core.fbs.FbsList;
14+
import io.github.dfa1.vortex.core.fbs.FbsPrimitive;
1315
import io.github.dfa1.vortex.core.fbs.FbsType;
1416
import org.junit.jupiter.api.Test;
1517
import org.junit.jupiter.params.ParameterizedTest;
@@ -158,6 +160,51 @@ void parseBlobs_layoutMetadata_oneOverLimit_throws() {
158160
.hasMessageContaining("metadata size");
159161
}
160162

163+
// ── FbsDType depth bound: depth > MAX_DTYPE_DEPTH (64) ──────────────────────────
164+
165+
@Test
166+
void parseBlobs_dtypeDepth_atLimit_parses() {
167+
// Given — a single-child List chain whose deepest node sits at exactly MAX_DTYPE_DEPTH.
168+
// convertDType is entered with depth == 64 there, and `64 > 64` is false. Kills the
169+
// `depth >` relaxed to `depth >=` mutant, which would reject the legal max-depth dtype.
170+
MemorySegment dtype = nestedListDtype(PostscriptParser.MAX_DTYPE_DEPTH);
171+
172+
// When
173+
DType result = parseDtype(dtype);
174+
175+
// Then
176+
assertThat(result).isInstanceOf(DType.List.class);
177+
}
178+
179+
@Test
180+
void parseBlobs_dtypeDepth_oneOverLimit_throws() {
181+
// Given — one level deeper: the deepest node reaches depth 65, tripping `65 > 64`
182+
MemorySegment dtype = nestedListDtype(PostscriptParser.MAX_DTYPE_DEPTH + 1);
183+
184+
// When / Then
185+
assertThatThrownBy(() -> parseDtype(dtype))
186+
.isInstanceOf(VortexException.class)
187+
.hasMessageContaining("depth");
188+
}
189+
190+
// ── parseBlobs dtype-presence guard: dtypeBuf != null && byteSize() > 0 ──────────
191+
192+
@Test
193+
void parseBlobs_emptyDtypeBlob_yieldsNullDtype() {
194+
// Given — a present but zero-length dtype blob. The guard treats an empty blob as "no dtype"
195+
// (`byteSize() > 0`); relaxing `> 0` to `>= 0` would instead feed the empty buffer to
196+
// getRootAsFbsDType and throw. Pins that an empty blob parses to a null dtype.
197+
MemorySegment footer = footerWithLayoutSpecs("vortex.flat");
198+
MemorySegment layout = flatLayout(0);
199+
MemorySegment emptyDtype = MemorySegment.ofArray(new byte[0]);
200+
201+
// When
202+
DType result = PostscriptParser.parseBlobs(footer, layout, emptyDtype).dtype();
203+
204+
// Then
205+
assertThat(result).isNull();
206+
}
207+
161208
// ── helpers ──────────────────────────────────────────────────────────────────
162209

163210
/// Parses a dtype blob through the full parseBlobs path, paired with a minimal valid
@@ -214,6 +261,20 @@ private static MemorySegment nestedLayout(int depth) {
214261
return slice(fbb);
215262
}
216263

264+
private static MemorySegment nestedListDtype(int depth) {
265+
var fbb = new FbsBuilder(depth * 32 + 64);
266+
// Leaf first; FlatBuffer requires children be finished before parents.
267+
int prim = FbsPrimitive.createFbsPrimitive(fbb, io.github.dfa1.vortex.core.fbs.FbsPType.I64, false);
268+
int current = io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsPrimitive, prim);
269+
// Wrap `depth` times: the innermost leaf ends up at recursion depth == `depth`.
270+
for (int i = 0; i < depth; i++) {
271+
int list = FbsList.createFbsList(fbb, current, false);
272+
current = io.github.dfa1.vortex.core.fbs.FbsDType.createFbsDType(fbb, FbsType.FbsList, list);
273+
}
274+
io.github.dfa1.vortex.core.fbs.FbsDType.finishFbsDTypeBuffer(fbb, current);
275+
return slice(fbb);
276+
}
277+
217278
private static MemorySegment decimalDtype(int precision, byte scale) {
218279
var fbb = new FbsBuilder(64);
219280
int dec = FbsDecimal.createFbsDecimal(fbb, precision, scale, false);

0 commit comments

Comments
 (0)