- Status: Accepted — implementation pending
- Date: 2026-06-13
- Deciders: project maintainer
- Related: CLAUDE.md §Security contract, TODO.md §Resource caps
VortexReader.open memory-maps the entire file into one MemorySegment and
parses the layout tree from the flatbuffer before any scan starts. There are
currently no limits on:
- File size —
channel.map(READ_ONLY, 0, size, arena)will happily try to map a 100 GB file, exhausting virtual address space. - Segment count — a crafted file can declare millions of segment entries in the postscript; each is a struct in the footer flatbuffer.
- Layout depth / children — the depth-bomb test exists but the child count per node is unchecked; a flat tree with 10 M children triggers the same allocation explosion.
- Row count per chunk — no upper bound; a chunk claiming
Long.MAX_VALUErows causes a downstream allocation ofrows * byteWidthbytes. - Max layout nodes — flat tree with enormous sibling count escapes the depth guard.
- Pco page/bin caps —
PcoEncodingDecoder/PcoBinhave no upper bound onbits_per_offset,bin_count, or per-page row count; in scope for the sameResourceLimitsmechanism, not a separate cap type.
The fix is a ResourceLimits value that is enforced at open/parse time,
before any byte is decoded.
The natural instinct is to hang these caps off ScanOptions, next to the
existing row limit. That is too late. The structural attacks detonate during
open() — before a ScanOptions even exists. open(path) already:
- memory-maps the entire file (
channel.map(READ_ONLY, 0, size, arena)) — a 100 GB file exhausts virtual address space here; - parses the postscript → footer → layout-tree flatbuffers;
- reads the segment table (a crafted file can declare millions of entries);
- walks the layout tree (depth / child-count bomb).
By the time a caller builds ScanOptions and calls scan(), the file is already
mapped and the layout tree already parsed — the OOM / address-space exhaustion /
depth-bomb has already happened. A scan-time check runs after the damage.
The governing rule: enforce each cap at the earliest point the resource is
consumed. For the structural caps that is open()/parse, not scan.
There is also a scope mismatch. Caps like maxFileSizeBytes and
maxSegmentCount are properties of the file + reader session, not of an
individual scan: one open() feeds many scan() calls. Placing them on
ScanOptions would force the caller to re-pass the same limit on every scan and
still could not guard open().
| Resource | Consumed / detonates at | Configured via |
|---|---|---|
| file mmap, segment table, layout depth / children / node count | open() / parse |
ReadOptions |
per-chunk decode allocation (rows × byteWidth) |
decode (during scan()) |
ReadOptions (maxRowsPerChunk, a layout-declared count fixed at open) |
| output row count | scan() |
ScanOptions.limit (already exists) |
So ScanOptions keeps the one genuinely per-scan knob (output limit); every
structural cap moves to a new open-time ReadOptions.
Three candidates:
| Placement | Checked when | Drawback |
|---|---|---|
ScanOptions (per-scan) |
Scan start | Too late for file-size and segment-count checks which happen at open() |
VortexReader.open overload param |
open() / parse() |
Splits configuration across open and scan; ScanOptions already owns scan-time limits |
New ReadOptions record |
open() |
Consistent with WriteOptions pattern; clear separation: open-time vs scan-time |
Decision: new ReadOptions record passed to VortexReader.open.
ScanOptions retains the existing limit field (scan-time row limit) and
gains nothing new.
Introduce ReadOptions with a ResourceLimits sub-record.
VortexReader.open(Path, ReadRegistry, ReadOptions) is the new canonical
overload; existing two-arg overload delegates with ReadOptions.defaults().
public record ResourceLimits(
long maxFileSizeBytes,
int maxSegmentCount,
int maxLayoutChildren,
int maxLayoutDepth,
long maxRowsPerChunk
) {
public static ResourceLimits defaults() {
return new ResourceLimits(
16L * 1024 * 1024 * 1024, // 16 GB
100_000, // segment entries
4_096, // children per layout node
64, // layout depth (existing guard)
(long) Integer.MAX_VALUE // rows per chunk
);
}
public static ResourceLimits unlimited() {
return new ResourceLimits(
Long.MAX_VALUE, Integer.MAX_VALUE,
Integer.MAX_VALUE, Integer.MAX_VALUE,
Long.MAX_VALUE);
}
}
public record ReadOptions(ResourceLimits limits) {
public static ReadOptions defaults() {
return new ReadOptions(ResourceLimits.defaults());
}
public ReadOptions withLimits(ResourceLimits limits) {
return new ReadOptions(limits);
}
}Enforcement points:
| Limit | Enforcement point |
|---|---|
maxFileSizeBytes |
VortexReader.open, before channel.map |
maxSegmentCount |
PostscriptParser.parse, after footer flatbuffer parsed |
maxLayoutChildren |
PostscriptParser.parseLayout (recursive), per node |
maxLayoutDepth |
existing LayoutDepthGuard; wire to limits.maxLayoutDepth() |
maxRowsPerChunk |
ScanIterator.decodeLayout, before arena allocation |
All violations throw VortexException with a message that includes the
limit value and the observed value (both are Java-internal, not
attacker-controlled strings, so no Sanitize required).
VortexHttpReader.open gains the same ReadOptions overload. maxFileSizeBytes
is checked against the Content-Length response header before the first
fetch; the others at the same points as the file reader.
ReadOptions.unlimited() is passed by all integration tests that open
fixtures produced by the Rust reference implementation — some fixtures
intentionally exercise large segment counts or chunk sizes.
ScanOptions.limit (row cap per scan) stays as-is. It is a different concern:
a user-facing query limit, not a security cap.
- Allocation bombs, mmap exhaustion, and layout-node explosions are caught before any decode work starts.
- Sane defaults protect production readers without any caller change.
ReadOptions.unlimited()makes integration tests self-documenting about which tests are security-relevant vs fixture tests.ResourceLimitsis a plain record — serializable, loggable, diffable.
VortexReader.open(Path, ReadRegistry)callers are unaffected (delegate overload), but new callers who want custom limits must constructReadOptions. Minor friction.- Default
maxFileSizeBytes = 16 GBmay be too small for some analytical workloads. Make it configurable from the start rather than hard-coding.