Skip to content

Commit 0dd677e

Browse files
dfa1claude
andcommitted
refactor(reader): Footer carries typed EncodingId/LayoutId specs
Footer.arraySpecs()/layoutSpecs() become List<EncodingId>/ List<LayoutId>. Total parse (unknown id -> Custom) removed the blocker that kept these stringly-typed; now the wire strings are parsed once at the footer boundary — with the blank-id guard — and array/layout nodes index directly into typed dictionaries. The two scattered per-node parse+blank-guard sites (SerializedArray- Decoder.convertArrayNode, PostscriptParser.convertLayout) collapse into two footer-level helpers; a blank encoding id is now unrepresentable below the boundary by construction, so its test moves to the footer parser. Inspector/Pco-diagnostic keep String display DTOs, converting at their own text-output boundary. Completes the typed-id arc: the spec dictionaries were the last stringly-typed holdout under ArrayNode/Layout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8fc59ea commit 0dd677e

11 files changed

Lines changed: 101 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ claim measured against the Rust (JNI) oracle; behavior divergences documented in
3131

3232
### Changed
3333

34+
- `Footer.arraySpecs()` / `Footer.layoutSpecs()` return typed `List<EncodingId>` / `List<LayoutId>` instead of `List<String>`. The wire strings are parsed to their typed ids once at the footer boundary (with the blank-id guard), so array and layout nodes index directly into typed dictionaries — completing "strings at the boundary, types inside": the two scattered per-node `parse` calls in `SerializedArrayDecoder` and the layout converter are gone. ([pending-footer](https://github.com/dfa1/vortex-java/commits/main))
35+
3436
- The CLI uber-jar is now fully self-contained for `vortex.zstd` files: it bundles the FFM binding's native `libzstd` for all six platforms (osx/linux/windows × x86_64/aarch64) via `zstd-platform` — previously it shipped the binding's classes but relied on a system libzstd. The library modules keep zstd optional. ([1983656b](https://github.com/dfa1/vortex-java/commit/1983656b))
3537
- The zstd binding stays a two-artifact opt-in (`io.github.dfa1.zstd:zstd` + `zstd-platform`, both `optional`; no JNI anywhere), and touching `vortex.zstd` without it now fails with an actionable `VortexException` naming the two artifacts to add, instead of a raw `NoClassDefFoundError`. Default write/read paths never touch the binding (measured). ([ae9f95cc](https://github.com/dfa1/vortex-java/commit/ae9f95cc))
3638
- The little-endian `ValueLayout` constants moved from `PTypeIO.LE_*` to `VortexFormat.LE_*` — endianness is a property of the wire format, not of ptypes, and `VortexFormat` is where format facts live. Six classes that carried private copies (including reversed-name duplicates like `SHORT_LE`) now share the single source; nothing outside `VortexFormat` defines a `withOrder(LITTLE_ENDIAN)` layout. ([c060d34f](https://github.com/dfa1/vortex-java/commit/c060d34f))

inspector/src/main/java/io/github/dfa1/vortex/inspect/InspectorTree.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static io.github.dfa1.vortex.core.io.VortexFormat.LE_INT;
44

55
import io.github.dfa1.vortex.reader.ArrayStats;
6+
import io.github.dfa1.vortex.core.model.EncodingId;
67
import io.github.dfa1.vortex.core.model.DType;
78
import io.github.dfa1.vortex.reader.Footer;
89
import io.github.dfa1.vortex.reader.layout.Layout;
@@ -112,7 +113,7 @@ public static InspectorTree buildShallow(VortexHandle handle) {
112113
handle.version(),
113114
handle.fileSize(),
114115
dtype,
115-
footer.arraySpecs(),
116+
footer.arraySpecs().stream().map(EncodingId::id).toList(),
116117
Set.of(),
117118
footer.segmentSpecs(),
118119
layout.rowCount(),
@@ -186,15 +187,15 @@ public static InspectorTree build(VortexHandle handle, Progress progress) {
186187
handle.version(),
187188
handle.fileSize(),
188189
dtype,
189-
footer.arraySpecs(),
190+
footer.arraySpecs().stream().map(EncodingId::id).toList(),
190191
Set.copyOf(overallUsed),
191192
footer.segmentSpecs(),
192193
layout.rowCount(),
193194
root);
194195
}
195196

196197
private static Node buildNode(Layout layout, Optional<String> fieldName, VortexHandle handle,
197-
List<String> arraySpecs, Set<String> overallUsed,
198+
List<EncodingId> arraySpecs, Set<String> overallUsed,
198199
Progress progress, int[] counter, int total) {
199200
Set<String> localUsed = new LinkedHashSet<>();
200201
ArrayStats stats = ArrayStats.empty();
@@ -253,7 +254,7 @@ public interface Progress {
253254
void update(int current, int total);
254255
}
255256

256-
private static Peek peekFlatRoot(MemorySegment seg, List<String> arraySpecs) {
257+
private static Peek peekFlatRoot(MemorySegment seg, List<EncodingId> arraySpecs) {
257258
int segLen = (int) seg.byteSize();
258259
int fbLen = seg.get(LE_INT, segLen - 4L);
259260
long fbStart = segLen - 4L - fbLen;
@@ -262,7 +263,7 @@ private static Peek peekFlatRoot(MemorySegment seg, List<String> arraySpecs) {
262263
if (root == null) {
263264
return new Peek(null, ArrayStats.empty());
264265
}
265-
return new Peek(arraySpecs.get(root.encoding()), ArrayStats.fromFbs(root.stats()));
266+
return new Peek(arraySpecs.get(root.encoding()).id(), ArrayStats.fromFbs(root.stats()));
266267
}
267268

268269
/// Result of a single Flat segment peek - the resolved encoding id (or

inspector/src/test/java/io/github/dfa1/vortex/inspect/InspectorTreeTest.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.github.dfa1.vortex.reader.CompressionScheme;
44
import io.github.dfa1.vortex.core.model.DType;
5+
import io.github.dfa1.vortex.core.model.EncodingId;
56
import io.github.dfa1.vortex.core.model.LayoutId;
67
import io.github.dfa1.vortex.reader.Footer;
78
import io.github.dfa1.vortex.reader.layout.Layout;
@@ -122,7 +123,7 @@ void build_carriesVersionAndFileSize() {
122123
given(handle.fileSize()).willReturn(123_456L);
123124
given(handle.dtype()).willReturn(dtype);
124125
given(handle.layout()).willReturn(root);
125-
given(handle.footer()).willReturn(new Footer(List.of("vortex.flat"), List.of(), List.of(), List.of()));
126+
given(handle.footer()).willReturn(new Footer(List.of(EncodingId.parse("vortex.flat")), List.of(), List.of(), List.of()));
126127

127128
// When
128129
InspectorTree sut = InspectorTree.build(handle);
@@ -231,7 +232,7 @@ void peek_compressedFlatSegment_returnsEmptyWithoutSlicing() {
231232
InspectorTree.Node node = new InspectorTree.Node(flat, java.util.Optional.empty(),
232233
Set.of(), io.github.dfa1.vortex.reader.ArrayStats.empty(), List.of());
233234
given(handle.footer()).willReturn(new io.github.dfa1.vortex.reader.Footer(
234-
List.of("vortex.flat"), List.of(),
235+
List.of(EncodingId.parse("vortex.flat")), List.of(),
235236
List.of(new SegmentSpec(0, 100, (byte) 0, CompressionScheme.ZSTD)),
236237
List.of()));
237238

@@ -265,7 +266,8 @@ void build_flatChildWithCompressedSegment_skipsRootEncodingPeek() {
265266
private void givenHandle(DType dtype, Layout layout, List<String> arraySpecs, List<SegmentSpec> segs) {
266267
given(handle.dtype()).willReturn(dtype);
267268
given(handle.layout()).willReturn(layout);
268-
given(handle.footer()).willReturn(new Footer(arraySpecs, List.of(), segs, List.of()));
269+
given(handle.footer()).willReturn(new Footer(
270+
arraySpecs.stream().map(EncodingId::parse).toList(), List.of(), segs, List.of()));
269271
}
270272

271273
private static Layout struct(long rows, List<Layout> children) {

integration/src/test/java/io/github/dfa1/vortex/integration/PcoFixtureInspectionIntegrationTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ private static void inspect(Path file, StringBuilder out) throws Exception {
5353
out.append("dtype: ").append(formatDType(vf.dtype())).append('\n');
5454
out.append("size: ").append(vf.fileSize()).append(" bytes\n");
5555

56-
List<String> arraySpecs = vf.footer().arraySpecs();
56+
List<String> arraySpecs = vf.footer().arraySpecs().stream()
57+
.map(io.github.dfa1.vortex.core.model.EncodingId::id).toList();
5758
List<SegmentSpec> segmentSpecs = vf.footer().segmentSpecs();
5859

5960
PcoStats stats = new PcoStats();

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
package io.github.dfa1.vortex.reader;
22

3+
import io.github.dfa1.vortex.core.model.EncodingId;
4+
import io.github.dfa1.vortex.core.model.LayoutId;
5+
36
import java.util.List;
47

58
/// Parsed file footer. Contains the dictionaries needed to resolve indices in the layout tree.
69
///
710
/// All spec lists are index-stable: array/layout/segment indices in the tree refer
8-
/// into these lists by position.
11+
/// into these lists by position. The wire strings are parsed to their typed ids once here,
12+
/// at the parse boundary, so array/layout nodes index directly into typed dictionaries.
913
///
10-
/// @param arraySpecs encoding id strings indexed by array spec index
11-
/// @param layoutSpecs layout type strings indexed by layout spec index
14+
/// @param arraySpecs [EncodingId] indexed by array spec index
15+
/// @param layoutSpecs [LayoutId] indexed by layout spec index
1216
/// @param segmentSpecs segment byte ranges indexed by segment index
1317
/// @param compressionSpecs compression schemes indexed by compression spec index
1418
public record Footer(
15-
List<String> arraySpecs,
16-
List<String> layoutSpecs,
19+
List<EncodingId> arraySpecs,
20+
List<LayoutId> layoutSpecs,
1721
List<SegmentSpec> segmentSpecs,
1822
List<CompressionScheme> compressionSpecs
1923
) {

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.github.dfa1.vortex.core.model.DType;
44
import io.github.dfa1.vortex.core.io.IoBounds;
5+
import io.github.dfa1.vortex.core.model.EncodingId;
56
import io.github.dfa1.vortex.core.model.LayoutId;
67
import io.github.dfa1.vortex.core.model.PType;
78
import io.github.dfa1.vortex.core.error.VortexException;
@@ -136,15 +137,43 @@ private static MemorySegment slice(MemorySegment seg, long offset, long length)
136137
return IoBounds.slice(seg, offset, length);
137138
}
138139

140+
/// Parses one array-spec wire string to its [EncodingId] at the footer boundary. A blank id
141+
/// is untrusted-input corruption; [EncodingId#parse] would reject it with an
142+
/// [IllegalArgumentException], so surface it as a [VortexException] with the spec index.
143+
///
144+
/// @param raw the wire string from the array spec dictionary
145+
/// @param index the array spec index, for the error message
146+
/// @return the typed [EncodingId]
147+
private static EncodingId parseEncodingSpec(String raw, int index) {
148+
if (raw.isBlank()) {
149+
throw new VortexException("blank encoding id at array spec index " + index);
150+
}
151+
return EncodingId.parse(raw);
152+
}
153+
154+
/// Parses one layout-spec wire string to its [LayoutId] at the footer boundary. A blank id
155+
/// is untrusted-input corruption; [LayoutId#parse] would reject it with an
156+
/// [IllegalArgumentException], so surface it as a [VortexException] with the spec index.
157+
///
158+
/// @param raw the wire string from the layout spec dictionary
159+
/// @param index the layout spec index, for the error message
160+
/// @return the typed [LayoutId]
161+
private static LayoutId parseLayoutSpec(String raw, int index) {
162+
if (raw.isBlank()) {
163+
throw new VortexException("blank layout id at layout spec index " + index);
164+
}
165+
return LayoutId.parse(raw);
166+
}
167+
139168
static Footer convertFooter(io.github.dfa1.vortex.core.fbs.FbsFooter f) {
140-
var arraySpecs = new ArrayList<String>(f.arraySpecsLength());
169+
var arraySpecs = new ArrayList<EncodingId>(f.arraySpecsLength());
141170
for (int i = 0; i < f.arraySpecsLength(); i++) {
142-
arraySpecs.add(f.arraySpecs(i).id());
171+
arraySpecs.add(parseEncodingSpec(f.arraySpecs(i).id(), i));
143172
}
144173

145-
var layoutSpecs = new ArrayList<String>(f.layoutSpecsLength());
174+
var layoutSpecs = new ArrayList<LayoutId>(f.layoutSpecsLength());
146175
for (int i = 0; i < f.layoutSpecsLength(); i++) {
147-
layoutSpecs.add(f.layoutSpecs(i).id());
176+
layoutSpecs.add(parseLayoutSpec(f.layoutSpecs(i).id(), i));
148177
}
149178

150179
var segmentSpecs = new ArrayList<SegmentSpec>(f.segmentSpecsLength());
@@ -166,7 +195,7 @@ static Footer convertFooter(io.github.dfa1.vortex.core.fbs.FbsFooter f) {
166195
List.copyOf(segmentSpecs), List.copyOf(compressionSpecs));
167196
}
168197

169-
private static Layout convertLayout(io.github.dfa1.vortex.core.fbs.FbsLayout l, List<String> layoutSpecs, int depth) {
198+
private static Layout convertLayout(io.github.dfa1.vortex.core.fbs.FbsLayout l, List<LayoutId> layoutSpecs, int depth) {
170199
if (depth > MAX_LAYOUT_DEPTH) {
171200
throw new VortexException(
172201
"layout tree depth exceeds limit (" + MAX_LAYOUT_DEPTH + ")");
@@ -177,13 +206,8 @@ private static Layout convertLayout(io.github.dfa1.vortex.core.fbs.FbsLayout l,
177206
"layout encoding index " + encIdx
178207
+ " out of bounds (layoutSpecs.size=" + layoutSpecs.size() + ")");
179208
}
180-
String rawLayoutId = layoutSpecs.get(encIdx);
181-
if (rawLayoutId.isBlank()) {
182-
// LayoutId.parse rejects blank ids with IllegalArgumentException; the file is
183-
// untrusted input, so a blank spec entry must surface as VortexException instead.
184-
throw new VortexException("blank layout id at layout spec index " + encIdx);
185-
}
186-
LayoutId layoutId = LayoutId.parse(rawLayoutId);
209+
// Already parsed (and blank-guarded) at the footer boundary.
210+
LayoutId layoutId = layoutSpecs.get(encIdx);
187211

188212
MemorySegment metadata = l.metadataAsSegment();
189213
if (metadata != null && metadata.byteSize() > MAX_LAYOUT_METADATA_BYTES) {

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

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,12 @@ public SerializedArrayDecoder(ReadRegistry registry) {
4949
/// Decodes a flat segment from a memory-mapped region.
5050
///
5151
/// @param seg memory-mapped region for the flat segment
52-
/// @param encodingSpecs ordered list of encoding id strings from the file's encoding table
52+
/// @param encodingSpecs ordered list of [EncodingId] from the file's encoding table
5353
/// @param dtype expected logical type for the decoded array
5454
/// @param rowCount number of logical rows in this segment
5555
/// @param arena allocator for decode output; lifetime matches the current chunk epoch
5656
/// @return the decoded [Array] for this segment
57-
public Array decode(MemorySegment seg, List<String> encodingSpecs,
57+
public Array decode(MemorySegment seg, List<EncodingId> encodingSpecs,
5858
DType dtype, long rowCount, SegmentAllocator arena) {
5959
int segLen = IoBounds.toIntSize(seg.byteSize());
6060

@@ -82,19 +82,15 @@ public Array decode(MemorySegment seg, List<String> encodingSpecs,
8282

8383
private static ArrayNode convertArrayNode(
8484
io.github.dfa1.vortex.core.fbs.FbsArrayNode fbs,
85-
List<String> encodingSpecs,
85+
List<EncodingId> encodingSpecs,
8686
int depth
8787
) {
8888
if (depth > MAX_ARRAY_TREE_DEPTH) {
8989
throw new VortexException(
9090
"array tree depth exceeds limit (" + MAX_ARRAY_TREE_DEPTH + ")");
9191
}
92-
String rawEncodingId = encodingSpecs.get(fbs.encoding());
93-
if (rawEncodingId.isBlank()) {
94-
// EncodingId.parse rejects blank ids with IllegalArgumentException; the file is
95-
// untrusted input, so a blank spec entry must surface as VortexException instead.
96-
throw new VortexException("blank encoding id at array spec index " + fbs.encoding());
97-
}
92+
// Already parsed (and blank-guarded) at the footer boundary.
93+
EncodingId encodingId = encodingSpecs.get(fbs.encoding());
9894

9995
ArrayNode[] children = new ArrayNode[fbs.childrenLength()];
10096
for (int i = 0; i < children.length; i++) {
@@ -107,6 +103,6 @@ private static ArrayNode convertArrayNode(
107103
}
108104

109105
MemorySegment meta = fbs.metadataAsSegment();
110-
return new ArrayNode(EncodingId.parse(rawEncodingId), meta, children, bufferIndices);
106+
return new ArrayNode(encodingId, meta, children, bufferIndices);
111107
}
112108
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.dfa1.vortex.core.fbs.FbsBuilder;
77
import io.github.dfa1.vortex.core.io.VortexFormat;
88
import io.github.dfa1.vortex.core.model.DType;
9+
import io.github.dfa1.vortex.core.model.EncodingId;
910
import org.junit.jupiter.api.Test;
1011

1112
import java.lang.foreign.Arena;
@@ -42,7 +43,7 @@ void arrayTreeAtDepthLimit_clearsGuard() {
4243
MemorySegment seg = wrapAsSegment(fb, arena);
4344

4445
// When / Then
45-
assertThatThrownBy(() -> sut.decode(seg, List.of("vortex.flat"), DTYPE, 1, arena))
46+
assertThatThrownBy(() -> sut.decode(seg, List.of(EncodingId.parse("vortex.flat")), DTYPE, 1, arena))
4647
.isInstanceOf(VortexException.class)
4748
.hasMessageContaining("no decoder");
4849
}
@@ -57,7 +58,7 @@ void arrayTreeOneOverDepthLimit_throwsVortexException() {
5758
MemorySegment seg = wrapAsSegment(fb, arena);
5859

5960
// When / Then — must surface as VortexException, not StackOverflowError
60-
assertThatThrownBy(() -> sut.decode(seg, List.of("vortex.flat"), DTYPE, 1, arena))
61+
assertThatThrownBy(() -> sut.decode(seg, List.of(EncodingId.parse("vortex.flat")), DTYPE, 1, arena))
6162
.isInstanceOf(VortexException.class)
6263
.hasMessageContaining("depth");
6364
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,36 @@ void convertDType_controlCharacterFieldName_throwsVortexException() {
8181
.hasMessageContaining("control character U+000A");
8282
}
8383

84+
@Test
85+
void convertFooter_blankArraySpec_throwsVortexException() {
86+
// Given — a footer whose array spec dictionary carries a blank encoding id (a zero-length
87+
// FlatBuffer string). Parsing now happens once at the footer boundary, so the blank guard
88+
// that used to live in the per-node decoder is exercised here.
89+
MemorySegment footer = footerWithArraySpec("");
90+
MemorySegment layout = flatLayout();
91+
MemorySegment dtype = structDType(new String[]{"a"}, 1);
92+
93+
// When / Then
94+
assertThatThrownBy(() -> PostscriptParser.parseBlobs(footer, layout, dtype))
95+
.isInstanceOf(VortexException.class)
96+
.hasMessageContaining("blank encoding id at array spec index 0");
97+
}
98+
8499
// ── FlatBuffer builders ─────────────────────────────────────────────────────
85100

101+
private static MemorySegment footerWithArraySpec(String arraySpec) {
102+
var fbb = new FbsBuilder(256);
103+
int asv = FbsFooter.createArraySpecsVector(fbb, new int[]{
104+
FbsArraySpec.createFbsArraySpec(fbb, fbb.createString(arraySpec))});
105+
int lsv = FbsFooter.createLayoutSpecsVector(fbb, new int[]{
106+
FbsLayoutSpec.createFbsLayoutSpec(fbb, fbb.createString("vortex.flat"))});
107+
FbsFooter.startSegmentSpecsVector(fbb, 0);
108+
int ssv = fbb.endVector();
109+
int footOff = FbsFooter.createFbsFooter(fbb, asv, lsv, ssv, 0, 0);
110+
fbb.finish(footOff);
111+
return fbb.dataSegment();
112+
}
113+
86114
/// Builds a struct dtype blob with the given field names and `dtypeCount` null-typed fields.
87115
private static MemorySegment structDType(String[] names, int dtypeCount) {
88116
var fbb = new FbsBuilder(256);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.dfa1.vortex.core.io.VortexFormat;
44
import io.github.dfa1.vortex.core.fbs.FbsBuilder;
55
import io.github.dfa1.vortex.core.model.DType;
6+
import io.github.dfa1.vortex.core.model.EncodingId;
67
import io.github.dfa1.vortex.core.error.VortexException;
78
import io.github.dfa1.vortex.core.fbs.FbsArray;
89
import io.github.dfa1.vortex.core.fbs.FbsArrayNode;
@@ -25,7 +26,7 @@ class SerializedArrayBoundsSecurityTest {
2526

2627

2728
private static final DType DTYPE = DType.I32;
28-
private static final List<String> FLAT_SPEC = List.of("vortex.flat");
29+
private static final List<EncodingId> FLAT_SPEC = List.of(EncodingId.parse("vortex.flat"));
2930

3031
private final SerializedArrayDecoder sut = new SerializedArrayDecoder(ReadRegistry.empty());
3132

0 commit comments

Comments
 (0)