diff --git a/zstd/src/test/java/io/github/dfa1/zstd/NativeLibraryTest.java b/zstd/src/test/java/io/github/dfa1/zstd/NativeLibraryTest.java index 3150178..e308be1 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/NativeLibraryTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/NativeLibraryTest.java @@ -6,6 +6,10 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.lang.foreign.FunctionDescriptor; +import java.lang.invoke.MethodHandle; + +import static java.lang.foreign.ValueLayout.JAVA_INT; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -48,6 +52,37 @@ void rejectsUnsupportedArchitecture() { } } + @Nested + class Lookup { + + @Test + void bindsAnExistingSymbolToAnInvokableHandle() throws Throwable { + // Given the descriptor for unsigned ZSTD_versionNumber(void) + FunctionDescriptor fd = FunctionDescriptor.of(JAVA_INT); + + // When the symbol is looked up + MethodHandle handle = NativeLibrary.lookup("ZSTD_versionNumber", fd); + + // Then a real, callable handle comes back (not null) and reports a version + assertThat(handle).isNotNull(); + assertThat((int) handle.invokeExact()).isPositive(); + } + + @Test + void rejectsAMissingSymbol() { + // Given a symbol the library does not export + FunctionDescriptor fd = FunctionDescriptor.of(JAVA_INT); + + // When it is looked up + ThrowingCallable result = () -> NativeLibrary.lookup("ZSTD_no_such_symbol_xyz", fd); + + // Then it fails fast naming the missing symbol, rather than returning null + assertThatThrownBy(result) + .isInstanceOf(UnsatisfiedLinkError.class) + .hasMessageContaining("Symbol not found"); + } + } + @Nested class LibraryExtension { diff --git a/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java b/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java index 9cf583f..a501290 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java @@ -179,6 +179,86 @@ void decompressRefPrefixRejectsAHeapSegment() { .hasMessageContaining("prefix"); } + @Test + void compressLoadDictionaryRejectsAHeapSegment() { + // Given a heap-backed segment + MemorySegment heap = MemorySegment.ofArray("dictionary".getBytes()); + + // When loaded as a zero-copy dictionary + ThrowingCallable result = () -> { + try (ZstdCompressCtx cctx = new ZstdCompressCtx()) { + cctx.loadDictionary(heap); + } + }; + + // Then it is rejected naming the offending argument + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dict"); + } + + @Test + void decompressLoadDictionaryRejectsAHeapSegment() { + // Given a heap-backed segment + MemorySegment heap = MemorySegment.ofArray("dictionary".getBytes()); + + // When loaded as a zero-copy dictionary + ThrowingCallable result = () -> { + try (ZstdDecompressCtx dctx = new ZstdDecompressCtx()) { + dctx.loadDictionary(heap); + } + }; + + // Then it is rejected naming the offending argument + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dict"); + } + + @Test + void refPrefixReturnsTheSameContextForChaining() { + // Given both contexts and a native prefix + try (Arena arena = Arena.ofConfined(); + ZstdCompressCtx cctx = new ZstdCompressCtx(); + ZstdDecompressCtx dctx = new ZstdDecompressCtx()) { + MemorySegment prefix = copy(arena, "a prior version".getBytes()); + + // When setting and then clearing a prefix on both contexts + ZstdCompressCtx cSet = cctx.refPrefix(prefix); + ZstdCompressCtx cCleared = cctx.refPrefix((MemorySegment) null); + ZstdDecompressCtx dSet = dctx.refPrefix(prefix); + ZstdDecompressCtx dCleared = dctx.refPrefix((MemorySegment) null); + + // Then every call returns the same instance, for chaining + assertThat(cSet).isSameAs(cctx); + assertThat(cCleared).isSameAs(cctx); + assertThat(dSet).isSameAs(dctx); + assertThat(dCleared).isSameAs(dctx); + } + } + + @Test + void loadDictionaryFromSegmentReturnsTheSameContextForChaining() { + // Given both contexts and a native dictionary segment + try (Arena arena = Arena.ofConfined(); + ZstdCompressCtx cctx = new ZstdCompressCtx(); + ZstdDecompressCtx dctx = new ZstdDecompressCtx()) { + MemorySegment dict = copy(arena, "dictionary sample payload ".repeat(64).getBytes()); + + // When loading and then clearing a segment dictionary on both contexts + ZstdCompressCtx cSet = cctx.loadDictionary(dict); + ZstdCompressCtx cCleared = cctx.loadDictionary(MemorySegment.NULL); + ZstdDecompressCtx dSet = dctx.loadDictionary(dict); + ZstdDecompressCtx dCleared = dctx.loadDictionary(MemorySegment.NULL); + + // Then every call returns the same instance, for chaining + assertThat(cSet).isSameAs(cctx); + assertThat(cCleared).isSameAs(cctx); + assertThat(dSet).isSameAs(dctx); + assertThat(dCleared).isSameAs(dctx); + } + } + private static MemorySegment copy(Arena arena, byte[] src) { MemorySegment seg = arena.allocate(src.length); MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length); diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java index 70f317b..fe399a6 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java @@ -25,6 +25,18 @@ void reportsDstSizeTooSmall() { assertThat(ex.code()).isEqualTo(ZstdErrorCode.DST_SIZE_TOO_SMALL); } + @Test + void carriesTheNativeErrorNameAsTheMessage() { + // Given a frame decompressed into too small a buffer + byte[] frame = Zstd.compress(PAYLOAD); + + // When it fails + ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, 1)); + + // Then the message is the descriptive native error name, not an empty string + assertThat(ex).hasMessageContaining("too small"); + } + @Test void reportsParameterOutOfBound() { try (ZstdCompressCtx ctx = new ZstdCompressCtx()) { diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java index 7e91ebb..c66cdf6 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.params.provider.EnumSource; import java.nio.charset.StandardCharsets; +import java.util.Random; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -311,5 +312,34 @@ void rejectsOutOfRangeValue() { assertThatThrownBy(result).isInstanceOf(ZstdException.class); } } + + @Test + void levelActuallyAppliesToTheNativeCompressionPath() { + // Given a level-sensitive payload (enough entropy that the level changes + // the ratio, so a no-op level() would betray itself) + byte[] data = levelSensitivePayload(); + byte[] atMin; + byte[] atMax; + + // When compressing via level() at the minimum and the maximum level + try (ZstdCompressCtx low = new ZstdCompressCtx().level(Zstd.minCompressionLevel()); + ZstdCompressCtx high = new ZstdCompressCtx().level(Zstd.maxCompressionLevel())) { + atMin = low.compress(data); + atMax = high.compress(data); + } + + // Then the higher level produces a strictly smaller frame — proving level() + // sets the native parameter rather than silently leaving the default + assertThat(atMax.length).isLessThan(atMin.length); + } + + private static byte[] levelSensitivePayload() { + byte[] b = new byte[64 * 1024]; + Random r = new Random(0xA11CE); + for (int i = 0; i < b.length; i++) { + b[i] = (byte) ((i % 17 == 0) ? r.nextInt(256) : 'a' + (i % 8)); + } + return b; + } } } diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java index e55b8e9..979aa15 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java @@ -97,6 +97,31 @@ void roundTripsWithDigestedDictionary() { assertThat(bytesOf(out, written)).isEqualTo(record); } } + + @Test + void arenaAllocatingDecompressSizesOutputFromTheDigestedDictionaryFrame() { + // Given a digested-dictionary frame that stores its decompressed size + ZstdDictionary dict = trainSmallDictionary(); + byte[] record = "{\"id\":99,\"user\":\"u\",\"active\":false}".getBytes(StandardCharsets.UTF_8); + + try (Arena arena = Arena.ofConfined(); + ZstdCompressCtx cctx = new ZstdCompressCtx(); + ZstdDecompressCtx dctx = new ZstdDecompressCtx(); + ZstdCompressDict cdict = new ZstdCompressDict(dict); + ZstdDecompressDict ddict = new ZstdDecompressDict(dict)) { + + MemorySegment src = segmentOf(arena, record); + MemorySegment frame = cctx.compress(arena, src, cdict); + + // When decoded through the arena-allocating ddict overload + MemorySegment out = dctx.decompress(arena, frame, ddict); + + // Then it allocates the exact size and returns the record (a non-null segment) + assertThat(out).isNotNull(); + assertThat(out.byteSize()).isEqualTo(record.length); + assertThat(bytesOf(out, out.byteSize())).isEqualTo(record); + } + } } @Nested diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java index f77a437..0b6baec 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.nio.ByteBuffer; @@ -252,6 +253,247 @@ void pledgedFrameDecodesZeroCopyIntoArenaInOneShot() throws IOException { } } + @Nested + class OutputStreamLifecycle { + + @Test + void flushPushesBufferedBytesThroughToTheSink() throws IOException { + // Given a payload written but not yet closed + byte[] original = "flushed payload ".repeat(2000).getBytes(StandardCharsets.UTF_8); + CountingOutputStream sink = new CountingOutputStream(); + try (ZstdOutputStream zout = new ZstdOutputStream(sink, 3)) { + zout.write(original); + + // When flushed mid-stream + zout.flush(); + + // Then the flush propagated to the underlying sink and emitted compressed bytes + assertThat(sink.flushes).isPositive(); + assertThat(sink.size()).isPositive(); + + // And the frame still completes and round-trips after the flush + zout.write(original); + } + byte[] doubled = new byte[original.length * 2]; + System.arraycopy(original, 0, doubled, 0, original.length); + System.arraycopy(original, 0, doubled, original.length, original.length); + assertThat(streamDecompress(sink.toByteArray())).isEqualTo(doubled); + } + + @Test + void closeFlushesAndClosesTheUnderlyingStreamExactlyOnce() throws IOException { + // Given a payload written to a stream that tracks flush/close on its sink + byte[] original = "epilogue payload ".repeat(1000).getBytes(StandardCharsets.UTF_8); + CountingOutputStream sink = new CountingOutputStream(); + ZstdOutputStream zout = new ZstdOutputStream(sink, 3); + zout.write(original); + + // When closed twice + zout.close(); + zout.close(); + + // Then the underlying sink was flushed and closed, the close being idempotent + assertThat(sink.flushes).isPositive(); + assertThat(sink.closes).isEqualTo(1); + + // And the written frame carries the full payload (epilogue was emitted) + assertThat(streamDecompress(sink.toByteArray())).isEqualTo(original); + } + + @Test + void writeAfterCloseThrows() throws IOException { + // Given a closed stream + ZstdOutputStream zout = new ZstdOutputStream(new ByteArrayOutputStream()); + zout.close(); + + // When written to + ThrowingCallable result = () -> zout.write(1); + + // Then it refuses with an IOException rather than touching freed native state + assertThatThrownBy(result) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + } + + @Test + void flushAfterCloseThrows() throws IOException { + // Given a closed stream + ZstdOutputStream zout = new ZstdOutputStream(new ByteArrayOutputStream()); + zout.close(); + + // When flushed + ThrowingCallable result = zout::flush; + + // Then it refuses with an IOException + assertThatThrownBy(result) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + } + } + + @Nested + class InputStreamLifecycle { + + @Test + void singleByteReadReturnsTheUnsignedValue() throws IOException { + // Given a frame whose first decoded byte has its high bit set + byte[] original = {(byte) 0xFF, (byte) 0x80, 0x01}; + byte[] frame = streamCompress(original, 3); + + // When read one byte at a time + try (ZstdInputStream zin = new ZstdInputStream(new ByteArrayInputStream(frame))) { + // Then each byte comes back as its unsigned 0..255 value, not a sign-extended int + assertThat(zin.read()).isEqualTo(0xFF); + assertThat(zin.read()).isEqualTo(0x80); + assertThat(zin.read()).isEqualTo(0x01); + assertThat(zin.read()).isEqualTo(-1); + } + } + + @Test + void readPastEndOfStreamStaysMinusOne() throws IOException { + // Given a compressed frame + byte[] frame = streamCompress("done".getBytes(StandardCharsets.UTF_8), 3); + try (ZstdInputStream zin = new ZstdInputStream(new ByteArrayInputStream(frame))) { + // When the stream is drained to the end, then read again past EOF + byte[] all = zin.readAllBytes(); + int afterByte = zin.read(); + int afterBlock = zin.read(new byte[8], 0, 8); + + // Then the content matches and both read overloads keep reporting EOF + assertThat(all).isEqualTo("done".getBytes(StandardCharsets.UTF_8)); + assertThat(afterByte).isEqualTo(-1); + assertThat(afterBlock).isEqualTo(-1); + } + } + + @Test + void readAfterCloseThrows() throws IOException { + // Given a closed input stream + byte[] frame = streamCompress("payload".getBytes(StandardCharsets.UTF_8), 3); + ZstdInputStream zin = new ZstdInputStream(new ByteArrayInputStream(frame)); + zin.close(); + + // When read + ThrowingCallable result = zin::read; + + // Then it refuses with an IOException rather than touching freed native state + assertThatThrownBy(result) + .isInstanceOf(IOException.class) + .hasMessageContaining("closed"); + } + + @Test + void closeClosesTheUnderlyingStreamExactlyOnce() throws IOException { + // Given an input stream over a source that tracks close() + byte[] frame = streamCompress("payload".getBytes(StandardCharsets.UTF_8), 3); + CountingInputStream source = new CountingInputStream(frame); + ZstdInputStream zin = new ZstdInputStream(source); + + // When closed twice + zin.close(); + zin.close(); + + // Then the underlying source was closed once, the close being idempotent + assertThat(source.closes).isEqualTo(1); + } + + @Test + void firstSingleByteIsCorrectEvenWhenInputDribblesInOneByteAtATime() throws IOException { + // Given a frame whose first decoded byte has its high bit set, fed one + // byte per read so the first decode calls produce nothing until the header + // is complete — the decoder must keep refilling before returning a byte + byte[] original = {(byte) 0xFF, 0x10, 0x20}; + byte[] frame = streamCompress(original, 3); + + // When the very first byte is read + try (ZstdInputStream zin = new ZstdInputStream(new DribbleInputStream(frame))) { + // Then it is the real first byte, not a premature zero from an empty slice + assertThat(zin.read()).isEqualTo(0xFF); + } + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 100, 64 * 1024}) + void readsCorrectlyWhenInputArrivesOneByteAtATime(int size) throws IOException { + // Given a frame fed through a source that yields a single byte per read, + // forcing the decoder across many refill/no-progress iterations + byte[] original = randomBytes(size); + byte[] frame = streamCompress(original, 3); + + // When drained + byte[] restored; + try (ZstdInputStream zin = new ZstdInputStream(new DribbleInputStream(frame))) { + restored = zin.readAllBytes(); + } + + // Then every byte survives the slow refill path + assertThat(restored).isEqualTo(original); + } + } + + /// A sink that records flush/close calls while retaining the bytes written to it + /// (a [ByteArrayOutputStream] whose close is a no-op, so bytes stay readable). + private static final class CountingOutputStream extends ByteArrayOutputStream { + private int flushes; + private int closes; + + @Override + public void flush() throws IOException { + flushes++; + super.flush(); + } + + @Override + public void close() throws IOException { + closes++; + super.close(); + } + } + + /// A source over a fixed byte array that records close() calls. + private static final class CountingInputStream extends ByteArrayInputStream { + private int closes; + + CountingInputStream(byte[] buf) { + super(buf); + } + + @Override + public void close() throws IOException { + closes++; + super.close(); + } + } + + /// A source that hands out at most one byte per read, exercising the decoder's + /// partial-input refill loop. + private static final class DribbleInputStream extends InputStream { + private final byte[] data; + private int pos; + + DribbleInputStream(byte[] data) { + this.data = data; + } + + @Override + public int read() { + return pos < data.length ? (data[pos++] & 0xFF) : -1; + } + + @Override + public int read(byte[] b, int off, int len) { + if (len == 0) { + return 0; + } + if (pos >= data.length) { + return -1; + } + b[off] = data[pos++]; + return 1; + } + } + private static byte[] streamCompress(byte[] data, int level) throws IOException { ByteArrayOutputStream sink = new ByteArrayOutputStream(); try (ZstdOutputStream zout = new ZstdOutputStream(sink, level)) {