Skip to content

Commit 86e31ca

Browse files
dfa1claude
andcommitted
feat: ranged byte[] compress/decompress overloads
Add (offset, length) sub-range overloads so callers holding a payload inside a larger buffer can compress/decompress it without copying the sub-range out first: - compress(byte[] src, int offset, int length) - compress(byte[] src, int offset, int length, int level) - decompress(byte[] compressed, int offset, int length, int maxSize) The range is validated with Objects.checkFromIndexSize before touching native memory. Whole-array overloads now delegate to the ranged ones, keeping the null-check semantics identical. A ranged copyIn helper backs the native copy. Closes #55 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 343341b commit 86e31ca

2 files changed

Lines changed: 212 additions & 7 deletions

File tree

zstd/src/main/java/io/github/dfa1/zstd/Zstd.java

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,44 @@ public static byte[] compress(byte[] src) {
4040
/// @return a self-describing zstd frame
4141
public static byte[] compress(byte[] src, int level) {
4242
Objects.requireNonNull(src, "src");
43+
return compress(src, 0, src.length, level);
44+
}
45+
46+
/// Compresses the `length`-byte sub-range of `src` starting at `offset`, at the
47+
/// library default level. Lets a caller holding a payload inside a larger buffer
48+
/// compress it without copying the sub-range out first.
49+
///
50+
/// @param src buffer holding the bytes to compress
51+
/// @param offset index of the first byte to compress
52+
/// @param length number of bytes to compress
53+
/// @return a self-describing zstd frame
54+
/// @throws IndexOutOfBoundsException if `offset` and `length` do not denote a
55+
/// valid range within `src`
56+
public static byte[] compress(byte[] src, int offset, int length) {
57+
return compress(src, offset, length, defaultCompressionLevel());
58+
}
59+
60+
/// Compresses the `length`-byte sub-range of `src` starting at `offset`, at the
61+
/// given level. Lets a caller holding a payload inside a larger buffer compress
62+
/// it without copying the sub-range out first.
63+
///
64+
/// @param src buffer holding the bytes to compress
65+
/// @param offset index of the first byte to compress
66+
/// @param length number of bytes to compress
67+
/// @param level compression level in [[#minCompressionLevel()], [#maxCompressionLevel()]];
68+
/// higher is smaller but slower
69+
/// @return a self-describing zstd frame
70+
/// @throws IndexOutOfBoundsException if `offset` and `length` do not denote a
71+
/// valid range within `src`
72+
public static byte[] compress(byte[] src, int offset, int length, int level) {
73+
Objects.requireNonNull(src, "src");
74+
Objects.checkFromIndexSize(offset, length, src.length);
4375
try (Arena arena = Arena.ofConfined()) {
44-
MemorySegment in = copyIn(arena, src);
45-
long bound = compressBound(src.length);
76+
MemorySegment in = copyIn(arena, src, offset, length);
77+
long bound = compressBound(length);
4678
MemorySegment out = arena.allocate(bound);
4779
long written = NativeCall.checkReturnValue(() -> (long) Bindings.COMPRESS.invokeExact(
48-
out, bound, in, (long) src.length, level));
80+
out, bound, in, (long) length, level));
4981
return copyOut(out, written);
5082
}
5183
}
@@ -101,11 +133,33 @@ private static int toArrayLength(long size) {
101133
/// @throws ZstdException if the frame is invalid or larger than `maxSize`
102134
public static byte[] decompress(byte[] compressed, int maxSize) {
103135
Objects.requireNonNull(compressed, "compressed");
136+
return decompress(compressed, 0, compressed.length, maxSize);
137+
}
138+
139+
/// Decompresses the `length`-byte sub-range of `compressed` starting at `offset`
140+
/// into a buffer of at most `maxSize` bytes. Lets a caller decode a frame embedded
141+
/// inside a larger buffer without copying the frame out first; otherwise identical
142+
/// to [#decompress(byte[], int)].
143+
///
144+
/// As with [#decompress(byte[], int)], `maxSize` caps the allocation and decode,
145+
/// so this is the safe entry point for **untrusted** input.
146+
///
147+
/// @param compressed buffer holding a complete zstd frame
148+
/// @param offset index of the first byte of the frame
149+
/// @param length number of bytes the frame occupies
150+
/// @param maxSize upper bound on the decompressed length
151+
/// @return the original bytes (length ≤ `maxSize`)
152+
/// @throws IndexOutOfBoundsException if `offset` and `length` do not denote a
153+
/// valid range within `compressed`
154+
/// @throws ZstdException if the frame is invalid or larger than `maxSize`
155+
public static byte[] decompress(byte[] compressed, int offset, int length, int maxSize) {
156+
Objects.requireNonNull(compressed, "compressed");
157+
Objects.checkFromIndexSize(offset, length, compressed.length);
104158
try (Arena arena = Arena.ofConfined()) {
105-
MemorySegment in = copyIn(arena, compressed);
159+
MemorySegment in = copyIn(arena, compressed, offset, length);
106160
MemorySegment out = arena.allocate(Math.max(maxSize, 1));
107161
long written = NativeCall.checkReturnValue(() -> (long) Bindings.DECOMPRESS.invokeExact(
108-
out, (long) maxSize, in, (long) compressed.length));
162+
out, (long) maxSize, in, (long) length));
109163
return copyOut(out, written);
110164
}
111165
}
@@ -316,8 +370,12 @@ public static int versionNumber() {
316370
// Native-call status checking and segment guards live in NativeCall.
317371

318372
static MemorySegment copyIn(Arena arena, byte[] src) {
319-
MemorySegment seg = arena.allocate(Math.max(src.length, 1));
320-
MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length);
373+
return copyIn(arena, src, 0, src.length);
374+
}
375+
376+
static MemorySegment copyIn(Arena arena, byte[] src, int offset, int length) {
377+
MemorySegment seg = arena.allocate(Math.max(length, 1));
378+
MemorySegment.copy(src, offset, seg, JAVA_BYTE, 0, length);
321379
return seg;
322380
}
323381

zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import java.lang.foreign.Arena;
1111
import java.lang.foreign.MemorySegment;
1212
import java.nio.charset.StandardCharsets;
13+
import java.util.Arrays;
1314

1415
import static org.assertj.core.api.Assertions.assertThat;
1516
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -150,6 +151,152 @@ void rejectsNullInputWithANamedMessage() {
150151
}
151152
}
152153

154+
@Nested
155+
class RangedOverloads {
156+
157+
@ParameterizedTest
158+
@MethodSource("io.github.dfa1.zstd.ZstdTestSupport#bytes")
159+
void rangedCompressRoundTripsToTheSubRange(byte[] payload) {
160+
// Given a payload embedded in the middle of a larger buffer
161+
byte[] buffer = embed(payload, 7, 5);
162+
163+
// When the sub-range is compressed and decompressed
164+
byte[] frame = Zstd.compress(buffer, 7, payload.length);
165+
byte[] restored = Zstd.decompress(frame, payload.length);
166+
167+
// Then the sub-range bytes come back exactly
168+
assertThat(restored).isEqualTo(payload);
169+
}
170+
171+
@ParameterizedTest
172+
@MethodSource("io.github.dfa1.zstd.ZstdTestSupport#levels")
173+
void rangedCompressEqualsCompressingTheExtractedSubRange(int level) {
174+
// Given a payload embedded in a larger buffer
175+
byte[] payload = ZstdTestSupport.randomBytes(42, 4096);
176+
byte[] buffer = embed(payload, 13, 9);
177+
178+
// When compressing the sub-range and the equivalent extracted copy
179+
byte[] ranged = Zstd.compress(buffer, 13, payload.length, level);
180+
byte[] extracted = Zstd.compress(Arrays.copyOfRange(buffer, 13, 13 + payload.length), level);
181+
182+
// Then the frames are identical — the range is honored
183+
assertThat(ranged).as("level %d", level).isEqualTo(extracted);
184+
}
185+
186+
@Test
187+
void fullRangeEqualsTheWholeArrayOverload() {
188+
// Given a payload compressed both ways
189+
byte[] payload = ZstdTestSupport.randomBytes(99, 2048);
190+
191+
// When using offset 0 + full length vs. the whole-array overload
192+
byte[] ranged = Zstd.compress(payload, 0, payload.length);
193+
byte[] whole = Zstd.compress(payload);
194+
195+
// Then they produce the same frame
196+
assertThat(ranged).isEqualTo(whole);
197+
}
198+
199+
@Test
200+
void emptyRangeRoundTrips() {
201+
// Given a non-empty buffer but a zero-length range
202+
byte[] buffer = "ignored payload".getBytes(StandardCharsets.UTF_8);
203+
204+
// When the empty range is compressed and decompressed
205+
byte[] frame = Zstd.compress(buffer, 4, 0);
206+
byte[] restored = Zstd.decompress(frame, 0);
207+
208+
// Then the result is empty
209+
assertThat(restored).isEmpty();
210+
}
211+
212+
@Test
213+
void rangedDecompressDecodesAnEmbeddedFrame() {
214+
// Given a frame embedded inside a larger buffer at an offset
215+
byte[] payload = ZstdTestSupport.randomBytes(7, 3000);
216+
byte[] frame = Zstd.compress(payload);
217+
byte[] buffer = new byte[frame.length + 20];
218+
System.arraycopy(frame, 0, buffer, 11, frame.length);
219+
220+
// When the embedded sub-range is decompressed
221+
byte[] restored = Zstd.decompress(buffer, 11, frame.length, payload.length);
222+
223+
// Then the original payload comes back
224+
assertThat(restored).isEqualTo(payload);
225+
}
226+
227+
@Test
228+
void rejectsNegativeOffset() {
229+
// Given a buffer
230+
byte[] buffer = new byte[16];
231+
232+
// When compressing with a negative offset
233+
ThrowingCallable result = () -> Zstd.compress(buffer, -1, 4);
234+
235+
// Then it fails before touching native memory
236+
assertThatThrownBy(result).isInstanceOf(IndexOutOfBoundsException.class);
237+
}
238+
239+
@Test
240+
void rejectsNegativeLength() {
241+
// Given a buffer
242+
byte[] buffer = new byte[16];
243+
244+
// When compressing with a negative length
245+
ThrowingCallable result = () -> Zstd.compress(buffer, 0, -1);
246+
247+
// Then it fails
248+
assertThatThrownBy(result).isInstanceOf(IndexOutOfBoundsException.class);
249+
}
250+
251+
@Test
252+
void rejectsRangeBeyondTheArray() {
253+
// Given a buffer
254+
byte[] buffer = new byte[16];
255+
256+
// When the range runs past the end of the array
257+
ThrowingCallable result = () -> Zstd.compress(buffer, 10, 10);
258+
259+
// Then it fails
260+
assertThatThrownBy(result).isInstanceOf(IndexOutOfBoundsException.class);
261+
}
262+
263+
@Test
264+
void rejectsNullSource() {
265+
// When null is passed to the ranged overloads
266+
ThrowingCallable compressNull = () -> Zstd.compress(null, 0, 0);
267+
ThrowingCallable decompressNull = () -> Zstd.decompress(null, 0, 0, 0);
268+
269+
// Then each fails fast naming its parameter
270+
assertThatThrownBy(compressNull)
271+
.isInstanceOf(NullPointerException.class)
272+
.hasMessageContaining("src");
273+
assertThatThrownBy(decompressNull)
274+
.isInstanceOf(NullPointerException.class)
275+
.hasMessageContaining("compressed");
276+
}
277+
278+
@Test
279+
void rejectsRangedDecompressBeyondTheArray() {
280+
// Given a buffer
281+
byte[] buffer = new byte[16];
282+
283+
// When the decompress range runs past the end of the array
284+
ThrowingCallable result = () -> Zstd.decompress(buffer, 10, 10, 16);
285+
286+
// Then it fails before touching native memory
287+
assertThatThrownBy(result).isInstanceOf(IndexOutOfBoundsException.class);
288+
}
289+
290+
// Embed `payload` into a larger buffer with `before` filler bytes ahead of it
291+
// and `after` filler bytes behind it, so callers exercise a real sub-range.
292+
private static byte[] embed(byte[] payload, int before, int after) {
293+
byte[] buffer = new byte[before + payload.length + after];
294+
Arrays.fill(buffer, (byte) 0x5A);
295+
System.arraycopy(payload, 0, buffer, before, payload.length);
296+
return buffer;
297+
}
298+
}
299+
153300
@Nested
154301
class Metadata {
155302

0 commit comments

Comments
 (0)