Skip to content

Commit 9663b16

Browse files
dfa1claude
andcommitted
feat(core): add IoBounds for untrusted-segment bounds typing (ADR 0003 Phase E)
Parse-side offsets/lengths/counts from untrusted file bytes must surface as VortexException, not raw IndexOutOfBoundsException / ArithmeticException / NegativeArraySizeException. IoBounds wraps the four shapes: slice/checkRange (asSlice bounds), toIntSize (2 GB ByteBuffer/array cap, replaces Math.toIntExact), checkCount (new T[n] alloc guard). Uses the current VortexException(String) constructor — bounds messages carry only numeric offsets, no attacker strings — and migrates to the VortexError catalog when ADR 0003 Phase A lands. Extends ADR 0003 to cover the exception *type* axis alongside message sanitization; records why a static helper beats the PR #27 BoundedSegment wrapper (no new type on the zero-copy hot path). Call-site migration + the Objects.checkIndex consumer-access sweep + the checkstyle ban on raw asSlice follow in subsequent commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5144f94 commit 9663b16

3 files changed

Lines changed: 342 additions & 2 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.github.dfa1.vortex.core;
2+
3+
import java.lang.foreign.MemorySegment;
4+
5+
/// Bounds-checked access to untrusted [MemorySegment] regions.
6+
///
7+
/// The reader memory-maps and parses attacker-controlled binary input. Per the
8+
/// [VortexException] contract, any malformed offset, length, or element count
9+
/// must surface as a [VortexException] — never a raw JDK
10+
/// [IndexOutOfBoundsException], [ArithmeticException], or
11+
/// [NegativeArraySizeException]. Offsets and lengths drawn from parsed file
12+
/// bytes flow through these helpers before they reach the JDK.
13+
///
14+
/// This covers the *parse* side only. A caller's random-access index into a
15+
/// decoded array (`array.getInt(5)`) is a different contract: that is consumer
16+
/// misuse and should throw [IndexOutOfBoundsException] via
17+
/// [java.util.Objects#checkIndex(long, long)], not a `VortexException`.
18+
///
19+
/// See ADR 0003 (`docs/adr/0003-vortex-exception-sanitization.md`).
20+
public final class IoBounds {
21+
22+
private IoBounds() {
23+
}
24+
25+
/// Verifies that the range `[off, off + len)` lies within `[0, size]`.
26+
///
27+
/// @param off start offset into the region
28+
/// @param len length of the range
29+
/// @param size total size of the region the range must fit within
30+
/// @throws VortexException if `off` or `len` is negative, or `off + len`
31+
/// exceeds `size` (overflow-safe: checks `len > size - off`)
32+
public static void checkRange(long off, long len, long size) {
33+
if (off < 0 || len < 0 || len > size - off) {
34+
throw new VortexException(
35+
"slice out of bounds: off=" + off + " len=" + len + " size=" + size);
36+
}
37+
}
38+
39+
/// Bounds-checked [MemorySegment#asSlice(long, long)] — the canonical
40+
/// replacement for a raw `asSlice` on an untrusted offset or length.
41+
///
42+
/// @param seg the segment to slice
43+
/// @param off start offset into `seg`
44+
/// @param len length of the slice
45+
/// @return the slice `seg[off, off + len)`
46+
/// @throws VortexException if the range falls outside `seg`
47+
public static MemorySegment slice(MemorySegment seg, long off, long len) {
48+
checkRange(off, len, seg.byteSize());
49+
return seg.asSlice(off, len);
50+
}
51+
52+
/// Narrows a `long` size or count to `int` for use as a [java.nio.ByteBuffer]
53+
/// index or a Java array length. Replaces [Math#toIntExact(long)] (which
54+
/// throws [ArithmeticException]) and guards the 2 GB `ByteBuffer` / array cap.
55+
///
56+
/// @param n the size or count, drawn from parsed file metadata
57+
/// @return `n` as an `int`
58+
/// @throws VortexException if `n` is negative or exceeds [Integer#MAX_VALUE]
59+
public static int toIntSize(long n) {
60+
if (n < 0 || n > Integer.MAX_VALUE) {
61+
throw new VortexException("size exceeds 2 GB limit: " + n);
62+
}
63+
return (int) n;
64+
}
65+
66+
/// Validates an element count before a `new T[count]` decode allocation.
67+
///
68+
/// Same guard as [#toIntSize(long)], named for the allocation call sites; a
69+
/// per-encoding resource cap (ADR 0004) plugs in here later.
70+
///
71+
/// @param n the element count, drawn from parsed file metadata
72+
/// @return `n` as an `int`
73+
/// @throws VortexException if `n` is negative or exceeds [Integer#MAX_VALUE]
74+
public static int checkCount(long n) {
75+
return toIntSize(n);
76+
}
77+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package io.github.dfa1.vortex.core;
2+
3+
import org.junit.jupiter.api.Nested;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.params.ParameterizedTest;
6+
import org.junit.jupiter.params.provider.CsvSource;
7+
import org.junit.jupiter.params.provider.ValueSource;
8+
9+
import java.lang.foreign.Arena;
10+
import java.lang.foreign.MemorySegment;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
14+
15+
class IoBoundsTest {
16+
17+
@Nested
18+
class CheckRange {
19+
20+
@ParameterizedTest
21+
// off, len against a size-16 region — every in-bounds case, including the
22+
// exact end (off+len == size) and a zero-length slice at the boundary.
23+
@CsvSource({"0,16", "0,0", "16,0", "4,8", "15,1"})
24+
void acceptsInBoundsRange(long off, long len) {
25+
// Given a region of 16 bytes
26+
// When / Then no exception
27+
IoBounds.checkRange(off, len, 16);
28+
}
29+
30+
@Test
31+
void rejectsNegativeOffset() {
32+
// Given / When / Then
33+
assertThatThrownBy(() -> IoBounds.checkRange(-1, 4, 16))
34+
.isInstanceOf(VortexException.class)
35+
.hasMessageContaining("out of bounds");
36+
}
37+
38+
@Test
39+
void rejectsNegativeLength() {
40+
// Given / When / Then
41+
assertThatThrownBy(() -> IoBounds.checkRange(0, -1, 16))
42+
.isInstanceOf(VortexException.class);
43+
}
44+
45+
@Test
46+
void rejectsRangePastEnd() {
47+
// Given off+len = 17 > size 16
48+
// When / Then
49+
assertThatThrownBy(() -> IoBounds.checkRange(10, 7, 16))
50+
.isInstanceOf(VortexException.class);
51+
}
52+
53+
@Test
54+
void rejectsOverflowingLengthWithoutWrapping() {
55+
// Given a crafted huge length that would overflow off+len if added naively;
56+
// the check uses len > size - off precisely to stay overflow-safe.
57+
assertThatThrownBy(() -> IoBounds.checkRange(8, Long.MAX_VALUE, 16))
58+
.isInstanceOf(VortexException.class);
59+
}
60+
}
61+
62+
@Nested
63+
class Slice {
64+
65+
@Test
66+
void returnsRequestedSubRegion() {
67+
try (Arena arena = Arena.ofConfined()) {
68+
// Given a 16-byte segment
69+
MemorySegment seg = arena.allocate(16);
70+
71+
// When slicing the middle 8 bytes
72+
MemorySegment result = IoBounds.slice(seg, 4, 8);
73+
74+
// Then the slice has the requested size
75+
assertThat(result.byteSize()).isEqualTo(8);
76+
}
77+
}
78+
79+
@Test
80+
void throwsVortexExceptionNotJdkOnOverflow() {
81+
try (Arena arena = Arena.ofConfined()) {
82+
// Given a 16-byte segment and an out-of-range request
83+
MemorySegment seg = arena.allocate(16);
84+
85+
// When / Then the contract holds — VortexException, never IndexOutOfBoundsException
86+
assertThatThrownBy(() -> IoBounds.slice(seg, 0, 32))
87+
.isInstanceOf(VortexException.class);
88+
}
89+
}
90+
}
91+
92+
@Nested
93+
class ToIntSize {
94+
95+
@ParameterizedTest
96+
@ValueSource(longs = {0, 1, 1024, Integer.MAX_VALUE})
97+
void narrowsValuesWithinIntRange(long n) {
98+
// Given / When / Then
99+
assertThat(IoBounds.toIntSize(n)).isEqualTo((int) n);
100+
}
101+
102+
@Test
103+
void rejectsValueAboveIntMax() {
104+
// Given a length one past the 2 GB ByteBuffer/array cap
105+
assertThatThrownBy(() -> IoBounds.toIntSize(Integer.MAX_VALUE + 1L))
106+
.isInstanceOf(VortexException.class)
107+
.hasMessageContaining("2 GB");
108+
}
109+
110+
@Test
111+
void rejectsNegativeValue() {
112+
// Given a length that read back negative (e.g. a u32 stored as signed)
113+
assertThatThrownBy(() -> IoBounds.toIntSize(-1))
114+
.isInstanceOf(VortexException.class);
115+
}
116+
}
117+
118+
@Nested
119+
class CheckCount {
120+
121+
@Test
122+
void delegatesToTheSameGuard() {
123+
// Given a valid count
124+
// When / Then it returns the narrowed value
125+
assertThat(IoBounds.checkCount(42)).isEqualTo(42);
126+
}
127+
128+
@Test
129+
void rejectsOversizedCount() {
130+
// Given a crafted huge element count for a new T[n] allocation
131+
assertThatThrownBy(() -> IoBounds.checkCount(Long.MAX_VALUE))
132+
.isInstanceOf(VortexException.class);
133+
}
134+
}
135+
}

docs/adr/0003-vortex-exception-sanitization.md

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# ADR 0003: Structured sanitization of `VortexException` messages
1+
# ADR 0003: `VortexException` contract — message sanitization and bounds typing
22

33
- **Status:** Accepted — implementation pending (see Phases below)
4-
- **Date:** 2026-06-13
4+
- **Date:** 2026-06-13 (bounds-typing scope added 2026-06-20)
55
- **Deciders:** project maintainer
66
- **Related:** [ADR 0001 — Split read and write runtimes](0001-split-read-and-write-runtimes.md),
7+
[ADR 0004 — Resource caps and `ReadOptions`](0004-resource-caps-read-options.md),
78
[SECURITY.md](../../SECURITY.md)
89

910
## Context
@@ -40,6 +41,37 @@ typed `EncodingId` for the attribution field but accepts a free-form `String`
4041
for the message body, which callers build via `+` or `.formatted()`. There is
4142
no sanitization contract.
4243

44+
### Second axis — exception *type*, not just message
45+
46+
Message sanitization governs *what a `VortexException` says*. A separate,
47+
orthogonal gap governs *whether a `VortexException` is thrown at all*. The
48+
reader's contract (SECURITY.md) is: any malformed input throws
49+
`VortexException`, never a raw JDK exception. But ~21 `MemorySegment.asSlice`
50+
call sites take offsets/lengths straight from untrusted layout/footer
51+
metadata and pass them to the JDK unguarded:
52+
53+
```java
54+
// ScanIterator — fbStart/fbLen decoded from an attacker FlatBuffer
55+
ByteBuffer fbBuf = seg.asSlice(fbStart, fbLen).asByteBuffer()... // raw IndexOutOfBoundsException on overflow
56+
```
57+
58+
Only `PostscriptParser` guards its slices (a private `slice()` +
59+
`checkBlobBounds()`); its own comment warns that every *other* scan-time
60+
`asSlice` would throw `IndexOutOfBoundsException` and break the contract. The
61+
same leak appears in three more shapes:
62+
63+
- `Math.toIntExact(storage.length())` (4 extension decoders) → raw
64+
`ArithmeticException` on a > 2 GB declared length.
65+
- `new byte[(int)(end - start)]` / `new long[(int) rowCount]` (VarBin, AlpRd,
66+
Delta) → `NegativeArraySizeException` / `OutOfMemoryError` on crafted
67+
non-monotonic offsets or huge counts.
68+
- `ByteBuffer` is `int`-indexed (2 GB cap); a slice fed to `asByteBuffer()`
69+
past that throws raw too.
70+
71+
These are the same `VortexException`-contract violation as a leaked ANSI
72+
escape — just on the type axis instead of the content axis — so they belong
73+
in the same ADR.
74+
4375
## Decision
4476

4577
**Pick Option A (enum error catalog) as the structural shape.** Add a
@@ -203,6 +235,73 @@ throw new VortexException(VortexError.UNKNOWN_LAYOUT_ENCODING, layout.encodingId
203235
The message format `[UNKNOWN_LAYOUT_ENCODING] vortex.flat\x0a<injected>` is
204236
machine-parseable, log-friendly, and injection-safe.
205237

238+
### Bounds typing: the `IoBounds` helper
239+
240+
A public static utility in `io.github.dfa1.vortex.core` (must be reachable by
241+
core itself — `ProtoReader` has a site — plus reader, `reader.array`, and
242+
`reader.decode`; `reader → core`, so core is the only home that covers all
243+
layers). It wraps the untrusted-offset operations and throws `VortexException`
244+
(via the `VortexError` catalog above) instead of the raw JDK exception:
245+
246+
```java
247+
public final class IoBounds {
248+
private IoBounds() {}
249+
250+
/// off/len must lie within [0, size]. Throws VortexException otherwise.
251+
public static void checkRange(long off, long len, long size) {
252+
if (off < 0 || len < 0 || len > size - off) {
253+
throw new VortexException(VortexError.SEGMENT_INDEX_OUT_OF_RANGE, off, len, size);
254+
}
255+
}
256+
257+
/// Bounds-checked asSlice — the canonical replacement for raw seg.asSlice.
258+
public static MemorySegment slice(MemorySegment seg, long off, long len) {
259+
checkRange(off, len, seg.byteSize());
260+
return seg.asSlice(off, len);
261+
}
262+
263+
/// long → int for sizes/counts that index a ByteBuffer or back a Java array.
264+
/// Replaces Math.toIntExact (ArithmeticException) and guards the 2 GB cap.
265+
public static int toIntSize(long n) {
266+
if (n < 0 || n > Integer.MAX_VALUE) {
267+
throw new VortexException(VortexError.SEGMENT_INDEX_OUT_OF_RANGE, n);
268+
}
269+
return (int) n;
270+
}
271+
272+
/// Element count for a `new T[n]` decode buffer; same guard as toIntSize,
273+
/// named for the alloc-count call sites (the per-encoding cap from ADR 0004
274+
/// plugs in here later).
275+
public static int checkCount(long n) {
276+
return toIntSize(n);
277+
}
278+
}
279+
```
280+
281+
Why a static helper, not a `BoundedSegment` wrapper (the approach explored in
282+
[PR #27](https://github.com/dfa1/vortex-java/pull/27)):
283+
284+
- The hot path is `MemorySegment` zero-copy slices; a wrapper type would have
285+
to be unwrapped at every typed accessor or it taxes per-element reads. A
286+
static call slices once, off the per-element path, and returns a plain
287+
`MemorySegment` — no new type crosses module boundaries.
288+
- It mirrors the `Sanitize` decision: one small pure primitive in `core`, not
289+
a new abstraction. `Sanitize` cleans the message; `IoBounds` types the
290+
throw. Symmetric.
291+
292+
#### The consumer-access carve-out
293+
294+
The ~14 per-element guards in `Lazy*` / `Materialized*` / `Generic` accessors
295+
(`getInt(i)` etc.) throw `IndexOutOfBoundsException` and **stay that way**
296+
they are *consumer* random-access (`array.getInt(5)`), where IOOBE is the
297+
correct JDK-idiomatic signal (cf. `List.get`), not a malformed-file event.
298+
These must **not** be routed through `IoBounds`. They are instead collapsed
299+
onto the JDK built-in `Objects.checkIndex(i, length)` (Java 16+) — stdlib, no
300+
custom helper. The dividing line:
301+
302+
- offset/length/count from **parsed file bytes**`IoBounds``VortexException`
303+
- index from a **caller's accessor argument**`Objects.checkIndex``IndexOutOfBoundsException`
304+
206305
## Migration phases
207306

208307
### Phase A — Foundation (~1.5 h)
@@ -244,6 +343,35 @@ approximate-fit existing ones.
244343
prevent regression. Also flag `+` inside the VortexException args to
245344
catch interpolation-before-sanitization.
246345

346+
### Phase E — Bounds typing via `IoBounds` (0.8.0)
347+
348+
Independent of A–D. The `VortexError` catalog (Phase A) is not built yet, so
349+
`IoBounds` ships using the current `VortexException(String)` constructor with a
350+
fixed, non-interpolated message (no attacker strings in the bounds messages —
351+
only numeric offsets/lengths, which need no sanitization). When Phase A lands,
352+
`IoBounds` migrates to `VortexError.SEGMENT_INDEX_OUT_OF_RANGE` mechanically
353+
with every other site. Lands in 0.8.0 before the release, since variant decode
354+
widens the parse surface.
355+
356+
1. Add `IoBounds` (`slice` / `checkRange` / `toIntSize` / `checkCount`) in
357+
`core` with unit tests: negative offset, length overflow, off+len past end,
358+
> 2 GB size, exact-boundary pass.
359+
2. Route the ~21 raw `asSlice` sites through `IoBounds.slice`; fold
360+
`PostscriptParser`'s private `slice()`/`checkBlobBounds` and `ProtoReader`'s
361+
hand-rolled guard into it.
362+
3. Replace `Math.toIntExact(...length())` (4 extension decoders) with
363+
`IoBounds.toIntSize`; guard the `new T[(int) n]` alloc sites with
364+
`IoBounds.checkCount`.
365+
4. Collapse the ~14 consumer-access `getX(i)` guards onto
366+
`Objects.checkIndex(i, length)` (separate commit — different error class,
367+
no `IoBounds`).
368+
5. Checkstyle `RegexpSingleline` rejecting raw `.asSlice(` in
369+
`reader` / `reader.array` / `reader.decode` / `core.proto` packages
370+
(mirrors the existing `<p>`-blocking rule), so new raw slices can't regress.
371+
6. `BoundsTypingSecurityTest`: crafted file with out-of-range slice offset,
372+
oversize declared length, and non-monotonic VarBin offsets each produce a
373+
`VortexException`, never a raw JDK exception.
374+
247375
## Alternative considered
248376

249377
**Option B — Sealed `VortexException` hierarchy:** Make `VortexException`

0 commit comments

Comments
 (0)