diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..ec24210
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,54 @@
+name: Publish to Maven Central
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ environment: maven
+ steps:
+ - name: Checkout (with zstd submodule)
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up JDK 25 with Maven Central publishing
+ uses: actions/setup-java@v4
+ with:
+ java-version: '25'
+ distribution: 'zulu'
+ cache: 'maven'
+ server-id: central
+ server-username: CENTRAL_USERNAME
+ server-password: CENTRAL_PASSWORD
+ gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+ gpg-passphrase: GPG_PASSPHRASE
+
+ - name: Set up Zig
+ uses: mlugg/setup-zig@v2
+ with:
+ version: 0.16.0
+
+ - name: Publish to Maven Central
+ env:
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
+ GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+ run: ./mvnw deploy -Prelease -DskipTests --batch-mode -ntp
+
+ - name: Create GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
+ gh release create "$GITHUB_REF_NAME" \
+ --title "$GITHUB_REF_NAME" \
+ --notes "$NOTES" \
+ --verify-tag
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e6eb741
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# Changelog
+
+All notable changes to this project are documented here. Format loosely follows
+[Keep a Changelog](https://keepachangelog.com/); versions are released as `v*`
+git tags, which trigger publication to Maven Central.
+
+## [0.1]
+
+First release. Java 25 Foreign Function & Memory (FFM) bindings for
+[Zstandard](https://github.com/facebook/zstd) 1.5.7, built hermetically from
+vendored source with `zig cc` (no JNI, no prebuilt binaries). 68 of the public
+zstd symbols are bound; see `docs/supported.md`.
+
+### Added
+- One-shot compression/decompression over `byte[]` and zero-copy `MemorySegment`
+ (`Zstd`, `ZstdCompressCtx`, `ZstdDecompressCtx`).
+- Dictionaries: training (`ZDICT_trainFromBuffer`, COVER / fast-COVER optimisers,
+ `finalizeDictionary`), digested `ZstdCompressDict` / `ZstdDecompressDict`,
+ dictionary ids and header size.
+- Streaming: `ZstdOutputStream` / `ZstdInputStream` (java.io) and a zero-copy
+ `MemorySegment` driver (`ZstdCompressStream` / `ZstdDecompressStream`), with
+ dictionaries, `pledgedSrcSize`, and live `progress()`.
+- All advanced parameters (`ZstdCompressParameter` / `ZstdDecompressParameter`)
+ with bounds queries; checksum, long-distance matching, window log, etc.
+- Frame inspection (`ZstdFrame`): header, content/compressed size, dictionary id,
+ skippable frames.
+- Typed errors (`ZstdException.code()` / `ZstdErrorCode`) and memory accounting
+ (`sizeOf()`, `Zstd.estimate*Size`).
+- Native artifacts for macOS, Linux and Windows on x86_64 and aarch64,
+ cross-compiled from a single host with `zig cc`.
+- Format-compatibility tests against the reference zstd-jni binding.
+
+[0.1]: https://github.com/dfa1/zstd-java/releases/tag/v0.1
diff --git a/bom/pom.xml b/bom/pom.xml
index ba791e0..1769bbf 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -12,6 +12,7 @@
zstd-java-bom
pom
zstd FFM BOM
+ Bill of materials for the zstd-java modules
diff --git a/native/linux-aarch64/pom.xml b/native/linux-aarch64/pom.xml
index ba4e575..5fb37e4 100644
--- a/native/linux-aarch64/pom.xml
+++ b/native/linux-aarch64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-linux-aarch64
zstd FFM Native linux-aarch64
+ Native zstd library for Linux aarch64
jar
diff --git a/native/linux-x86_64/pom.xml b/native/linux-x86_64/pom.xml
index 830bca4..8a0ef81 100644
--- a/native/linux-x86_64/pom.xml
+++ b/native/linux-x86_64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-linux-x86_64
zstd FFM Native linux-x86_64
+ Native zstd library for Linux x86_64
jar
diff --git a/native/osx-aarch64/pom.xml b/native/osx-aarch64/pom.xml
index 0e86ac8..588d627 100644
--- a/native/osx-aarch64/pom.xml
+++ b/native/osx-aarch64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-osx-aarch64
zstd FFM Native osx-aarch64
+ Native zstd library for macOS aarch64
jar
diff --git a/native/osx-x86_64/pom.xml b/native/osx-x86_64/pom.xml
index 2c4f9fe..3d6a439 100644
--- a/native/osx-x86_64/pom.xml
+++ b/native/osx-x86_64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-osx-x86_64
zstd FFM Native osx-x86_64
+ Native zstd library for macOS x86_64
jar
diff --git a/native/windows-aarch64/pom.xml b/native/windows-aarch64/pom.xml
index a13789a..8b6e707 100644
--- a/native/windows-aarch64/pom.xml
+++ b/native/windows-aarch64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-windows-aarch64
zstd FFM Native windows-aarch64
+ Native zstd library for Windows aarch64
jar
diff --git a/native/windows-x86_64/pom.xml b/native/windows-x86_64/pom.xml
index f05557c..1b0065e 100644
--- a/native/windows-x86_64/pom.xml
+++ b/native/windows-x86_64/pom.xml
@@ -11,6 +11,7 @@
zstd-java-native-windows-x86_64
zstd FFM Native windows-x86_64
+ Native zstd library for Windows x86_64
jar
diff --git a/pom.xml b/pom.xml
index c4ebb5a..891dfe6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,6 +27,13 @@
+
+ scm:git:https://github.com/dfa1/zstd-java.git
+ scm:git:git@github.com:dfa1/zstd-java.git
+ https://github.com/dfa1/zstd-java
+ HEAD
+
+
native/osx-aarch64
native/osx-x86_64
@@ -43,6 +50,11 @@
UTF-8
25
+ 0.11.0
+ 3.2.8
+ 3.4.0
+ 3.12.0
+ 3.3.1
@@ -164,6 +176,17 @@
+
+ org.apache.maven.plugins
+ maven-release-plugin
+ ${maven-release-plugin.version}
+
+ true
+ v@{project.version}
+ false
+ true
+
+
@@ -186,4 +209,86 @@
+
+
+
+
+ release
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ ${maven-source-plugin.version}
+
+
+ attach-sources
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ ${maven-javadoc-plugin.version}
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+ none
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ ${maven-gpg-plugin.version}
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+
+ --pinentry-mode
+ loopback
+
+
+
+
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ ${central-publishing-plugin.version}
+ true
+
+ central
+ true
+ published
+
+
+
+
+
+
diff --git a/zstd/pom.xml b/zstd/pom.xml
index 22f7ee6..70bda93 100644
--- a/zstd/pom.xml
+++ b/zstd/pom.xml
@@ -11,6 +11,7 @@
zstd-java
zstd FFM Core
+ Java Foreign Function & Memory (FFM) bindings for zstd
jar
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java
index f7ba90c..98495f0 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java
@@ -91,6 +91,7 @@ public static byte[] decompress(byte[] compressed, int maxSize) {
/// @return the decompressed length in bytes
/// @throws ZstdException if the frame is invalid or does not store its size
public static long decompressedSize(MemorySegment frame) {
+ requireNative(frame, "frame");
long size;
try {
size = (long) Bindings.GET_FRAME_CONTENT_SIZE.invokeExact(frame, frame.byteSize());
@@ -275,6 +276,17 @@ private static String errorName(long code) {
}
}
+ /// Guards a zero-copy entry point: the segment handed to zstd must be backed
+ /// by native (off-heap) memory, since its address is dereferenced in C. Fails
+ /// fast with a clear message instead of the FFM linker's cryptic error.
+ static MemorySegment requireNative(MemorySegment seg, String name) {
+ if (!seg.isNative()) {
+ throw new IllegalArgumentException(
+ name + " must be a native (off-heap) MemorySegment; got a heap segment");
+ }
+ return seg;
+ }
+
static MemorySegment copyIn(Arena arena, byte[] src) {
MemorySegment seg = arena.allocate(Math.max(src.length, 1));
MemorySegment.copy(src, 0, seg, JAVA_BYTE, 0, src.length);
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressCtx.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressCtx.java
index f69aef1..1ce8c82 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressCtx.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressCtx.java
@@ -162,6 +162,8 @@ public byte[] compress(byte[] src, ZstdCompressDict dict) {
/// @return the number of bytes written into `dst`
/// @throws ZstdException if `dst` is too small or compression fails
public long compress(MemorySegment dst, MemorySegment src) {
+ Zstd.requireNative(dst, "dst");
+ Zstd.requireNative(src, "src");
return Zstd.call(() -> (long) Bindings.COMPRESS2.invokeExact(
ptr(), dst, dst.byteSize(), src, src.byteSize()));
}
@@ -173,6 +175,8 @@ public long compress(MemorySegment dst, MemorySegment src) {
/// @param dict the pre-digested compression dictionary
/// @return the number of bytes written into `dst`
public long compress(MemorySegment dst, MemorySegment src, ZstdCompressDict dict) {
+ Zstd.requireNative(dst, "dst");
+ Zstd.requireNative(src, "src");
MemorySegment cdict = dict.ptr();
return Zstd.call(() -> (long) Bindings.COMPRESS_USING_CDICT.invokeExact(
ptr(), dst, dst.byteSize(), src, src.byteSize(), cdict));
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java
index 44c7443..3216e04 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java
@@ -86,6 +86,8 @@ private static MemorySegment create(int level, ZstdDictionary dictionary) {
/// @return how much was consumed and produced, and the remaining hint
/// @throws ZstdException if compression fails
public ZstdStreamResult compress(MemorySegment dst, MemorySegment src, ZstdEndDirective directive) {
+ Zstd.requireNative(dst, "dst");
+ Zstd.requireNative(src, "src");
in.set(src, src.byteSize(), 0);
out.set(dst, dst.byteSize(), 0);
long remaining = Zstd.call(() -> (long) Bindings.COMPRESS_STREAM2.invokeExact(
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressCtx.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressCtx.java
index 8ed9d6c..23bde76 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressCtx.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressCtx.java
@@ -115,6 +115,8 @@ public byte[] decompress(byte[] compressed, int maxSize, ZstdDecompressDict dict
/// @return the number of bytes written into `dst`
/// @throws ZstdException if `dst` is too small or the frame is invalid
public long decompress(MemorySegment dst, MemorySegment src) {
+ Zstd.requireNative(dst, "dst");
+ Zstd.requireNative(src, "src");
return Zstd.call(() -> (long) Bindings.DECOMPRESS_DCTX.invokeExact(
ptr(), dst, dst.byteSize(), src, src.byteSize()));
}
@@ -126,6 +128,8 @@ public long decompress(MemorySegment dst, MemorySegment src) {
/// @param dict the pre-digested decompression dictionary
/// @return the number of bytes written into `dst`
public long decompress(MemorySegment dst, MemorySegment src, ZstdDecompressDict dict) {
+ Zstd.requireNative(dst, "dst");
+ Zstd.requireNative(src, "src");
MemorySegment ddict = dict.ptr();
return Zstd.call(() -> (long) Bindings.DECOMPRESS_USING_DDICT.invokeExact(
ptr(), dst, dst.byteSize(), src, src.byteSize(), ddict));
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java
index 380c5af..0623a6a 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java
@@ -1,12 +1,15 @@
package io.github.dfa1.zstd;
import java.lang.foreign.Arena;
+import java.lang.foreign.MemoryLayout;
+import java.lang.foreign.MemoryLayout.PathElement;
import java.lang.foreign.MemorySegment;
import java.lang.invoke.MethodHandle;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static java.lang.foreign.ValueLayout.JAVA_BYTE;
+import static java.lang.foreign.ValueLayout.JAVA_DOUBLE;
import static java.lang.foreign.ValueLayout.JAVA_INT;
import static java.lang.foreign.ValueLayout.JAVA_LONG;
@@ -28,6 +31,39 @@
/// }
public final class ZstdDictionary {
+ // ZDICT_cover_params_t { unsigned k,d,steps,nbThreads; double splitPoint;
+ // unsigned shrinkDict, shrinkDictMaxRegression; ZDICT_params_t zParams; }
+ // ZDICT_params_t { int compressionLevel; unsigned notificationLevel, dictID; }
+ private static final MemoryLayout COVER_PARAMS = MemoryLayout.structLayout(
+ JAVA_INT.withName("k"),
+ JAVA_INT.withName("d"),
+ JAVA_INT.withName("steps"),
+ JAVA_INT.withName("nbThreads"),
+ JAVA_DOUBLE.withName("splitPoint"),
+ JAVA_INT.withName("shrinkDict"),
+ JAVA_INT.withName("shrinkDictMaxRegression"),
+ JAVA_INT.withName("compressionLevel"),
+ JAVA_INT.withName("notificationLevel"),
+ JAVA_INT.withName("dictID"),
+ MemoryLayout.paddingLayout(4)); // trailing pad to the C struct's 8-byte alignment
+
+ // ZDICT_fastCover_params_t adds `unsigned f` after d and `unsigned accel`
+ // after splitPoint; the 4-byte gap before the 8-aligned double is explicit.
+ private static final MemoryLayout FASTCOVER_PARAMS = MemoryLayout.structLayout(
+ JAVA_INT.withName("k"),
+ JAVA_INT.withName("d"),
+ JAVA_INT.withName("f"),
+ JAVA_INT.withName("steps"),
+ JAVA_INT.withName("nbThreads"),
+ MemoryLayout.paddingLayout(4),
+ JAVA_DOUBLE.withName("splitPoint"),
+ JAVA_INT.withName("accel"),
+ JAVA_INT.withName("shrinkDict"),
+ JAVA_INT.withName("shrinkDictMaxRegression"),
+ JAVA_INT.withName("compressionLevel"),
+ JAVA_INT.withName("notificationLevel"),
+ JAVA_INT.withName("dictID"));
+
private final byte[] bytes;
private ZstdDictionary(byte[] bytes) {
@@ -154,11 +190,10 @@ private static ZstdDictionary optimize(List samples, int maxDictBytes,
offset += s.length;
}
// zeroed params (auto-tune k/d/steps); set single-threaded + target level.
- // fastCover: nbThreads@16, compressionLevel@44, size 56.
- // cover: nbThreads@12, compressionLevel@32, size 48.
- MemorySegment params = arena.allocate(fast ? 56 : 48);
- params.set(JAVA_INT, fast ? 16 : 12, 1);
- params.set(JAVA_INT, fast ? 44 : 32, compressionLevel);
+ MemoryLayout layout = fast ? FASTCOVER_PARAMS : COVER_PARAMS;
+ MemorySegment params = arena.allocate(layout);
+ params.set(JAVA_INT, layout.byteOffset(PathElement.groupElement("nbThreads")), 1);
+ params.set(JAVA_INT, layout.byteOffset(PathElement.groupElement("compressionLevel")), compressionLevel);
MethodHandle handle = fast ? Bindings.ZDICT_OPTIMIZE_FASTCOVER : Bindings.ZDICT_OPTIMIZE_COVER;
MemorySegment dictBuf = arena.allocate(maxDictBytes);
long produced;
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdInputStream.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdInputStream.java
index 435b400..cb6d246 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdInputStream.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdInputStream.java
@@ -35,10 +35,14 @@ public final class ZstdInputStream extends InputStream {
private final ZstdStreamBuffer outBufView = new ZstdStreamBuffer(arena);
private final byte[] feed;
private final byte[] hold;
+ private final byte[] single = new byte[1];
private int holdStart;
private int holdEnd;
private boolean inputEof;
private boolean closed;
+ /// zstd's "bytes still expected" hint from the last decompressStream call;
+ /// non-zero at EOF means the final frame was truncated.
+ private long lastHint;
/// Wraps `in`, decompressing the zstd frame it carries.
///
@@ -84,9 +88,8 @@ private void loadDictionary(ZstdDictionary dictionary) {
@Override
public int read() throws IOException {
- byte[] one = new byte[1];
- int n = read(one, 0, 1);
- return n == -1 ? -1 : (one[0] & 0xFF);
+ int n = read(single, 0, 1);
+ return n == -1 ? -1 : (single[0] & 0xFF);
}
@Override
@@ -112,13 +115,20 @@ private boolean produce() throws IOException {
int r = inputEof ? -1 : in.read(feed);
if (r == -1) {
inputEof = true;
+ // The frame boundary is clean only when the last decompressStream
+ // call reported nothing outstanding; otherwise the stream was cut
+ // mid-frame and the remaining bytes are lost.
+ if (lastHint != 0) {
+ throw new ZstdException("truncated zstd stream: " + lastHint
+ + " more input byte(s) expected");
+ }
return false;
}
MemorySegment.copy(feed, 0, inSeg, JAVA_BYTE, 0, r);
inBuf.set(inSeg, r, 0);
}
outBufView.set(outSeg, outCap, 0);
- Zstd.call(() -> (long) Bindings.DECOMPRESS_STREAM.invokeExact(
+ lastHint = Zstd.call(() -> (long) Bindings.DECOMPRESS_STREAM.invokeExact(
dctx, outBufView.segment(), inBuf.segment()));
int produced = Math.toIntExact(outBufView.pos());
if (produced > 0) {
@@ -127,10 +137,17 @@ private boolean produce() throws IOException {
holdEnd = produced;
return true;
}
- // nothing produced: if input is exhausted and the underlying stream is
- // at EOF, there is no more output to come.
- if (inputEof && inBuf.pos() == inBuf.size()) {
- return false;
+ // Nothing produced. If the decoder neither advanced its input nor wants
+ // more, it cannot make progress on this input — stop to avoid spinning.
+ if (inBuf.pos() == inBuf.size()) {
+ if (inputEof) {
+ if (lastHint != 0) {
+ throw new ZstdException("truncated zstd stream: " + lastHint
+ + " more input byte(s) expected");
+ }
+ return false;
+ }
+ // input drained but frame wants more: loop to refill from `in`.
}
}
}
diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java
index 84f8c80..db8cedb 100644
--- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java
+++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java
@@ -41,6 +41,7 @@ public final class ZstdOutputStream extends OutputStream {
private final ZstdStreamBuffer in = new ZstdStreamBuffer(arena);
private final ZstdStreamBuffer outBuf = new ZstdStreamBuffer(arena);
private final byte[] drain;
+ private final byte[] single = new byte[1];
private boolean closed;
/// Wraps `out`, compressing at the library default level.
@@ -124,7 +125,8 @@ private void loadDictionary(ZstdDictionary dictionary) {
@Override
public void write(int b) throws IOException {
- write(new byte[]{(byte) b}, 0, 1);
+ single[0] = (byte) b;
+ write(single, 0, 1);
}
@Override
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 cc9ebe9..7f43296 100644
--- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java
+++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java
@@ -11,6 +11,7 @@
import static java.lang.foreign.ValueLayout.JAVA_BYTE;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ZstdSegmentTest {
@@ -97,6 +98,45 @@ void roundTripsWithDigestedDictionary() {
}
}
+ @Nested
+ class HeapSegmentGuard {
+
+ @Test
+ void compressRejectsHeapSource() {
+ // Given a heap-backed segment (not off-heap) handed to the zero-copy API
+ MemorySegment heap = MemorySegment.ofArray(new byte[64]);
+ try (Arena arena = Arena.ofConfined();
+ ZstdCompressCtx cctx = new ZstdCompressCtx()) {
+ MemorySegment dst = arena.allocate(64);
+
+ // Then it fails fast with a clear message instead of a cryptic FFM error
+ assertThatThrownBy(() -> cctx.compress(dst, heap))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("native");
+ }
+ }
+
+ @Test
+ void decompressRejectsHeapDestination() {
+ MemorySegment heapDst = MemorySegment.ofArray(new byte[64]);
+ try (Arena arena = Arena.ofConfined();
+ ZstdDecompressCtx dctx = new ZstdDecompressCtx()) {
+ MemorySegment src = arena.allocate(64);
+
+ assertThatThrownBy(() -> dctx.decompress(heapDst, src))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("native");
+ }
+ }
+
+ @Test
+ void decompressedSizeRejectsHeapFrame() {
+ assertThatThrownBy(() -> Zstd.decompressedSize(MemorySegment.ofArray(new byte[8])))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("native");
+ }
+ }
+
private static MemorySegment segmentOf(Arena arena, byte[] bytes) {
MemorySegment seg = arena.allocate(Math.max(bytes.length, 1));
MemorySegment.copy(bytes, 0, seg, JAVA_BYTE, 0, bytes.length);
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 4e28b64..8937c1f 100644
--- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java
+++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java
@@ -12,6 +12,7 @@
import java.util.Random;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ZstdStreamTest {
@@ -148,6 +149,37 @@ private byte[] record(int i) {
}
}
+ @Nested
+ class Truncation {
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 8, 64})
+ void throwsWhenFinalFrameIsCutShort(int bytesDropped) throws IOException {
+ // Given a valid frame with its tail bytes lopped off (random data so the
+ // frame stays large enough to drop bytes from)
+ byte[] original = randomBytes(50_000);
+ byte[] frame = streamCompress(original, 6);
+ byte[] cut = java.util.Arrays.copyOf(frame, frame.length - bytesDropped);
+
+ // When the streaming decompressor drains it
+ // Then it reports the truncation instead of returning a clean EOF
+ try (ZstdInputStream zin = new ZstdInputStream(new ByteArrayInputStream(cut))) {
+ assertThatThrownBy(zin::readAllBytes)
+ .isInstanceOf(ZstdException.class)
+ .hasMessageContaining("truncated");
+ }
+ }
+
+ @Test
+ void emptyInputIsCleanEofNotTruncation() throws IOException {
+ // Given no input at all
+ // When read, it is end-of-stream, not an error
+ try (ZstdInputStream zin = new ZstdInputStream(new ByteArrayInputStream(new byte[0]))) {
+ assertThat(zin.read()).isEqualTo(-1);
+ }
+ }
+ }
+
@Nested
class PledgedSize {