Skip to content

Commit febfcc1

Browse files
dfa1claude
andcommitted
fix: harden vortex.zoned metadata parsing against malformed input; decimal sum fallback (#197)
The aggregate-spec zone-map decoder added in #197 (commit 9e20edc) had two post-merge review findings. Overflow class: ProtoCursor guarded reads with `pos + len > bound`. A varint length up to Long.MAX_VALUE overflows `pos + len` to negative, so the `> bound` check is false and the guard passes — then `new byte[(int) len]` throws NegativeArraySizeException, or `pos` advances negative and the next segment read throws IndexOutOfBoundsException. This is untrusted file metadata (ADR 0003), so it must never surface a raw JDK exception. Every length/offset check (readAggregateSpecId message descent, readString, advance/skipField) is rewritten overflow-safe: the invariant pos <= bound holds throughout, so `bound - pos >= 0` never overflows and the guard becomes `len < 0 || len > bound - pos`. Malformed metadata now falls back to per-chunk stats. Decimal divergence: Rust's default_zoned_aggregate_fns (vortex-layout zoned/writer.rs) emits a `sum` column whenever Sum.return_dtype is Some — which is Some(Decimal(precision + 10)) for a Decimal column (sum/mod.rs) — while our sumDtype returns null for Decimal. Dropping that field with `continue` reconstructs {max,min,null_count} for an encoded {max,min,sum,null_count} table, so the positional decode reads null_count out of the sum buffer. The reconstructor now distinguishes "Rust also omits this" (nan_count over non-float per nan_count/mod.rs, min/max over an unsupported column per min_max minmax_supported_dtype — safe skip) from "Rust keeps a field we cannot map" (decimal sum, bounded_max/min nested state — return null so the whole column falls back to correct-but-unpruned per-chunk stats). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e20edc commit febfcc1

4 files changed

Lines changed: 196 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Per-zone stats from current Rust writers (`vortex.zoned`, vortex-jni 0.76.0) decode again. The 0.76 zoned layout replaced the legacy `vortex.stats` bit-set metadata with an aggregate-function spec list and dropped the per-stat truncation flags; the reader now reconstructs the stats table from that spec list (min/max/sum/null_count), so `columnZoneStats` and aggregate push-down work against those files instead of throwing `ClassCastException`. ([#197](https://github.com/dfa1/vortex-java/pull/197))
13+
- Hardened the `vortex.zoned` metadata decoder against malformed input: an attacker-controlled length varint could overflow its `pos + len` bounds checks and crash with `NegativeArraySizeException`/`IndexOutOfBoundsException`; the checks are now overflow-safe and unparseable metadata falls back to per-chunk stats. Decimal columns — whose Rust zone table keeps a `sum` field this reader cannot map — now also fall back instead of decoding a misaligned table. ([#197](https://github.com/dfa1/vortex-java/pull/197))
1314
- CSV export renders nested struct columns as JSON object cells (`{"field":value,...}`, strings JSON-escaped, null fields as JSON `null`, nested structs recursed) instead of throwing `unsupported array type: StructArray`. ([#217](https://github.com/dfa1/vortex-java/issues/217))
1415
- Scanning a struct whose columns include a nested struct no longer fails chunk planning. A nested `vortex.struct` layout column (e.g. Raincloud `countries-of-the-world`'s `data` column) is now treated as a single full-range chunk source and decoded through the layout registry's new struct decoder into a `StructArray`, matching the Rust reference (a nested struct spans its parent's row range, not an independent chunking). `schema`/`count`/`inspect` already worked; plain scans and `select` of sibling columns now work too. CSV export of the struct column itself remains unsupported (a separate rendering limitation). ([#207](https://github.com/dfa1/vortex-java/issues/207))
1516
- CSV export renders unsigned integer columns (U8–U64) with their unsigned values — high-half values previously printed as two's-complement negatives (uci-wine `magnesium` U8 132 exported as -124), silent corruption found by the Raincloud conformance suite. ([#208](https://github.com/dfa1/vortex-java/issues/208))

docs/explanation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ file-level `SegmentSpec[]` table). Five node types exist today:
222222
| ID | Constant | Children | Role |
223223
|-----------------|-----------|-----------|------|
224224
| `vortex.struct` | `STRUCT` | N | Row type. One child per column. Root of every file. |
225-
| `vortex.stats` | `ZONED` | 1 | Wraps a child layout and carries per-chunk min/max as zone maps. Pruned at scan time when filter predicate falls outside `[min, max]`. |
225+
| `vortex.stats` | `ZONED` | 1 | Wraps a child layout and carries a per-zone stats table as a zone map. The legacy `vortex.stats` form declares its columns with a `Stat` bitset; the Rust >= 0.76 `vortex.zoned` form declares them with an aggregate-spec list, adding `sum`/`null_count` alongside `min`/`max`. Pruned at scan time when the filter predicate falls outside `[min, max]`. |
226226
| `vortex.chunked`| `CHUNKED` | M (+1) | Row-group sequence. Optional stats child at index 0 when `metadata[0] == 1` (per-chunk stats sidecar); remaining children are the data chunks. |
227227
| `vortex.dict` | `DICT` | 2 | Dictionary-encoded leaf. `children[0]` = values layout, `children[1]` = codes layout. `metadata` holds the codes `PType` (varint, proto field 1). Decoder gathers values by code. |
228228
| `vortex.flat` | `FLAT` | 0 | Leaf. References one `SegmentSpec` via `segments[0]`. Decoded by the encoding named in the segment's `arraySpec`, not by `encodingId` itself — see below. |

reader/src/main/java/io/github/dfa1/vortex/reader/layout/ZonedStatsSchema.java

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@
2929
/// each get an extra trailing field `max_is_truncated` /
3030
/// `min_is_truncated` of type `Bool` (non-nullable).
3131
///
32-
/// `Sum` widening rules and Decimal handling are not yet implemented —
33-
/// when the column dtype has no resolvable stat dtype the stat is skipped so
34-
/// the inspector degrades to "no schema" rather than failing.
32+
/// `Sum` widening for Decimal columns is not yet modeled. The legacy bitset path
33+
/// ([#statsTableDtype(DType, List)]) skips any stat with no resolvable dtype so the inspector
34+
/// degrades to "no schema" rather than failing. The aggregate-spec path
35+
/// ([#aggregateStatsTableDtype(DType, MemorySegment)]) is positional, so it only skips fields Rust
36+
/// also omits and otherwise returns `null` (fall back to per-chunk stats) rather than emit a
37+
/// misaligned struct.
3538
public final class ZonedStatsSchema {
3639

3740
/// Ordinal positions of [Stat] in the Rust enum — kept stable across
@@ -171,16 +174,52 @@ public static DType.Struct aggregateStatsTableDtype(DType columnDtype, MemorySeg
171174
}
172175
DType stype = statDtype(stat, columnDtype);
173176
if (stype == null) {
174-
// Rust's schema drops aggregates with no state dtype for this column (e.g.
175-
// nan_count over a non-float); skip to stay aligned with the encoded table.
176-
continue;
177+
if (rustAlsoOmits(stat, columnDtype)) {
178+
// Rust's aggregate_stats_table_dtype also drops this field (its aggregate's
179+
// state_dtype is None for this column), so skipping stays aligned with the
180+
// encoded table.
181+
continue;
182+
}
183+
// Rust keeps a field here that we cannot map — notably Sum over a Decimal column,
184+
// whose state widens to Decimal(precision + 10) (writer.rs default_zoned_aggregate_fns
185+
// only emits Sum when Sum.return_dtype is Some, which it is for Decimal). Dropping it
186+
// would misalign the positional decode, so bail to per-chunk stats.
187+
return null;
177188
}
178189
names.add(ColumnName.of(stat.fieldName()));
179190
types.add(stype.withNullable(true));
180191
}
181192
return new DType.Struct(List.copyOf(names), List.copyOf(types), false);
182193
}
183194

195+
/// Reports whether Rust's `aggregate_stats_table_dtype` also omits this aggregate for the given
196+
/// column — i.e. the aggregate's `state_dtype` is `None` there, so dropping it keeps our
197+
/// positional schema aligned with the encoded table. Any other null resolution means Rust keeps
198+
/// a field we cannot describe, and the caller must bail instead.
199+
///
200+
/// The two legitimate drops mirror the Rust reference
201+
/// ([vortex-array/src/aggregate_fn/fns](https://github.com/spiraldb/vortex/tree/develop/vortex-array/src/aggregate_fn/fns)):
202+
/// - `nan_count` returns `None` for non-float columns (`nan_count/mod.rs`);
203+
/// - `min`/`max` return `None` when `minmax_supported_dtype` is false — for us that is only
204+
/// `DType.Null`, the sole column for which the min/max resolver yields null.
205+
private static boolean rustAlsoOmits(Stat stat, DType columnDtype) {
206+
return switch (stat) {
207+
case NAN_COUNT -> !isFloatingColumn(columnDtype);
208+
case MAX, MIN -> true;
209+
default -> false;
210+
};
211+
}
212+
213+
private static boolean isFloatingColumn(DType columnDtype) {
214+
if (columnDtype instanceof DType.Primitive p) {
215+
return p.ptype().isFloating();
216+
}
217+
if (columnDtype instanceof DType.Extension ext) {
218+
return isFloatingColumn(ext.storageDType());
219+
}
220+
return false;
221+
}
222+
184223
/// Reconstructs the per-zone stats-table dtype for the given column dtype
185224
/// and explicit stat list (already decoded from metadata).
186225
///
@@ -400,7 +439,10 @@ boolean skipField(int wireType) {
400439
/// or `null` if the message is malformed or carries no id.
401440
String readAggregateSpecId() {
402441
long len = readVarint();
403-
if (len < 0 || pos + len > end) {
442+
// Overflow-safe bound: pos <= end holds throughout, so end - pos never overflows; a
443+
// varint-derived len up to Long.MAX_VALUE would overflow pos + len to negative and slip
444+
// past a pos + len > end guard, then advance pos past the segment.
445+
if (len < 0 || len > end - pos) {
404446
return null;
405447
}
406448
long messageEnd = pos + len;
@@ -426,7 +468,11 @@ String readAggregateSpecId() {
426468

427469
private String readString(long limit) {
428470
long len = readVarint();
429-
if (len < 0 || pos + len > limit) {
471+
// Overflow-safe bound (see readAggregateSpecId): compare against limit - pos, never
472+
// pos + len. If a prior varint pushed pos past limit, limit - pos is negative and any
473+
// non-negative len is rejected, so we never size new byte[(int) len] from an
474+
// attacker-controlled, overflowed length.
475+
if (len < 0 || len > limit - pos) {
430476
return null;
431477
}
432478
byte[] bytes = new byte[(int) len];
@@ -436,7 +482,10 @@ private String readString(long limit) {
436482
}
437483

438484
private boolean advance(long count) {
439-
if (count < 0 || pos + count > end) {
485+
// Overflow-safe bound: pos <= end holds throughout, so end - pos never overflows,
486+
// whereas pos + count could overflow to negative for a varint-derived count near
487+
// Long.MAX_VALUE and slip past a pos + count > end guard.
488+
if (count < 0 || count > end - pos) {
440489
return false;
441490
}
442491
pos += count;

reader/src/test/java/io/github/dfa1/vortex/reader/layout/ZonedStatsSchemaTest.java

Lines changed: 136 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -256,17 +256,17 @@ void buildsNumericSchemaWithoutTruncationFlags() {
256256
"vortex.max", "vortex.min", "vortex.sum", "vortex.nan_count", "vortex.null_count");
257257

258258
// When
259-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
259+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
260260

261261
// Then — no `_is_truncated` fields (the new format has none) and nan_count is dropped
262-
assertThat(schema.fieldNames().stream().map(ColumnName::value).toList())
262+
assertThat(result.fieldNames().stream().map(ColumnName::value).toList())
263263
.containsExactly("max", "min", "sum", "null_count");
264-
assertThat(schema.fieldTypes()).containsExactly(
264+
assertThat(result.fieldTypes()).containsExactly(
265265
new DType.Primitive(PType.I64, true),
266266
new DType.Primitive(PType.I64, true),
267267
new DType.Primitive(PType.I64, true),
268268
new DType.Primitive(PType.U64, true));
269-
assertThat(schema.nullable()).isFalse();
269+
assertThat(result.nullable()).isFalse();
270270
}
271271

272272
@Test
@@ -275,12 +275,12 @@ void keepsNanCountForFloatColumn() {
275275
MemorySegment meta = aggregateMeta(1024, "vortex.max", "vortex.nan_count");
276276

277277
// When
278-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.F64, meta);
278+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.F64, meta);
279279

280280
// Then
281-
assertThat(schema.fieldNames().stream().map(ColumnName::value).toList())
281+
assertThat(result.fieldNames().stream().map(ColumnName::value).toList())
282282
.containsExactly("max", "nan_count");
283-
assertThat(schema.fieldTypes()).element(1).isEqualTo(new DType.Primitive(PType.U64, true));
283+
assertThat(result.fieldTypes()).element(1).isEqualTo(new DType.Primitive(PType.U64, true));
284284
}
285285

286286
@Test
@@ -290,10 +290,27 @@ void bailsOnUnknownAggregate() {
290290
MemorySegment meta = aggregateMeta(4096, "vortex.bounded_max", "vortex.null_count");
291291

292292
// When
293-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.UTF8, meta);
293+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.UTF8, meta);
294294

295295
// Then
296-
assertThat(schema).isNull();
296+
assertThat(result).isNull();
297+
}
298+
299+
@Test
300+
void bailsForDecimalSumColumn() {
301+
// Given — Rust's default_zoned_aggregate_fns emits a `sum` column for a Decimal column
302+
// (Sum.return_dtype widens to Decimal(precision + 10)), but this reader cannot map a
303+
// decimal sum state. Reconstructing {max, min, null_count} while the encoded table is
304+
// {max, min, sum, null_count} would read null_count out of the sum buffer, so the whole
305+
// column must fall back to per-chunk (unpruned) stats rather than emit a mis-sized struct.
306+
MemorySegment meta = aggregateMeta(8192,
307+
"vortex.max", "vortex.min", "vortex.sum", "vortex.null_count");
308+
309+
// When
310+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.decimal(20, 4), meta);
311+
312+
// Then
313+
assertThat(result).isNull();
297314
}
298315

299316
@Test
@@ -303,19 +320,19 @@ void bailsOnUnsupportedVersion() {
303320
meta.set(java.lang.foreign.ValueLayout.JAVA_BYTE, 0, (byte) 2);
304321

305322
// When
306-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
323+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
307324

308325
// Then
309-
assertThat(schema).isNull();
326+
assertThat(result).isNull();
310327
}
311328

312329
@Test
313330
void bailsOnNullMetadata() {
314331
// Given / When — a zoned layout with no metadata cannot be reconstructed
315-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, null);
332+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, null);
316333

317334
// Then
318-
assertThat(schema).isNull();
335+
assertThat(result).isNull();
319336
}
320337

321338
@Test
@@ -324,11 +341,103 @@ void returnsEmptyStructWhenNoAggregatesPresent() {
324341
MemorySegment meta = aggregateMeta(8192);
325342

326343
// When
327-
DType.Struct schema = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
344+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(DType.I64, meta);
328345

329346
// Then — an empty (non-null) struct; the caller falls back to per-chunk stats
330-
assertThat(schema).isNotNull();
331-
assertThat(schema.fieldNames()).isEmpty();
347+
assertThat(result).isNotNull();
348+
assertThat(result.fieldNames()).isEmpty();
349+
}
350+
}
351+
352+
@Nested
353+
class MalformedAggregateMetadata {
354+
// These blobs are untrusted file metadata. Each malformed shape must degrade to the
355+
// fallback (null) return — never a raw JDK exception (NegativeArraySizeException /
356+
// IndexOutOfBoundsException), which the pre-fix overflow-unsafe bounds checks allowed.
357+
358+
@Test
359+
void bailsOnTruncatedVarint() {
360+
// Given — the only field tag is an unterminated varint (continuation bit set, no next
361+
// byte); the cursor must fail rather than read past the buffer.
362+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
363+
out.write(1); // envelope version
364+
out.write(0x80); // dangling varint continuation byte
365+
366+
// When
367+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(
368+
DType.I64, MemorySegment.ofArray(out.toByteArray()));
369+
370+
// Then
371+
assertThat(result).isNull();
372+
}
373+
374+
@Test
375+
void bailsOnOversizedStringLength() {
376+
// Given — a framed AggregateSpecProto whose id-string length varint is Long.MAX_VALUE.
377+
// The pre-fix `pos + len > limit` guard overflowed to negative and passed, then
378+
// `new byte[(int) len]` threw NegativeArraySizeException.
379+
java.io.ByteArrayOutputStream spec = new java.io.ByteArrayOutputStream();
380+
writeTag(spec, 1, 2); // id, wire LEN
381+
writeVarintLong(spec, Long.MAX_VALUE); // absurd string length
382+
byte[] specBytes = spec.toByteArray();
383+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
384+
out.write(1); // envelope version
385+
writeTag(out, 2, 2); // aggregate_specs, wire LEN
386+
writeVarint(out, specBytes.length);
387+
out.writeBytes(specBytes);
388+
389+
// When / Then — must not throw NegativeArraySizeException
390+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(
391+
DType.I64, MemorySegment.ofArray(out.toByteArray()));
392+
assertThat(result).isNull();
393+
}
394+
395+
@Test
396+
void bailsOnOversizedSkippedFieldLength() {
397+
// Given — a non-target length-delimited field whose length is Long.MAX_VALUE. The
398+
// pre-fix advance guard `pos + count > end` overflowed and advanced pos negative,
399+
// throwing IndexOutOfBoundsException on the next read.
400+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
401+
out.write(1); // envelope version
402+
writeTag(out, 1, 2); // field 1 with wire LEN → skipped, not an aggregate spec
403+
writeVarintLong(out, Long.MAX_VALUE);
404+
405+
// When / Then — must not throw IndexOutOfBoundsException
406+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(
407+
DType.I64, MemorySegment.ofArray(out.toByteArray()));
408+
assertThat(result).isNull();
409+
}
410+
411+
@Test
412+
void bailsOnUnknownWireType() {
413+
// Given — a field using wire type 3 (group start), which the cursor cannot skip.
414+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
415+
out.write(1); // envelope version
416+
writeTag(out, 3, 3); // field 3, wire type 3 (unsupported)
417+
418+
// When
419+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(
420+
DType.I64, MemorySegment.ofArray(out.toByteArray()));
421+
422+
// Then
423+
assertThat(result).isNull();
424+
}
425+
426+
@Test
427+
void bailsOnMessageLengthPastEnd() {
428+
// Given — an aggregate_specs field whose declared length runs past the buffer end.
429+
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
430+
out.write(1); // envelope version
431+
writeTag(out, 2, 2); // aggregate_specs, wire LEN
432+
writeVarint(out, 64); // claims 64 bytes...
433+
out.write(0x01); // ...but only one follows
434+
435+
// When
436+
DType.Struct result = ZonedStatsSchema.aggregateStatsTableDtype(
437+
DType.I64, MemorySegment.ofArray(out.toByteArray()));
438+
439+
// Then
440+
assertThat(result).isNull();
332441
}
333442
}
334443

@@ -372,4 +481,15 @@ private static void writeVarint(java.io.ByteArrayOutputStream out, int value) {
372481
}
373482
out.write(v);
374483
}
484+
485+
/// Writes an unsigned base-128 varint of a `long` — used to inject absurd (overflow-inducing)
486+
/// lengths like `Long.MAX_VALUE` that a plain `int` varint cannot express.
487+
private static void writeVarintLong(java.io.ByteArrayOutputStream out, long value) {
488+
long v = value;
489+
while ((v & ~0x7fL) != 0) {
490+
out.write((int) ((v & 0x7f) | 0x80));
491+
v >>>= 7;
492+
}
493+
out.write((int) v);
494+
}
375495
}

0 commit comments

Comments
 (0)