From fe7935faa774398354ce9c08f72a076bd412975d Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sat, 25 Jul 2026 13:05:14 +0200 Subject: [PATCH 1/2] refactor: finish value-object migration for size/window-log/magic-variant Stabilizes the public API by removing the remaining primitive-obsession stragglers, so the value-object story is complete before the surface freezes. Sizes -> ZstdByteSize: - ZstdFrame.compressedSize / headerSize / decompressionMargin (were long) - ZstdDictionary.size() / headerSize() (were int) Now consistent with decompressedSize/decompressedBound/sizeOf(), which already returned ZstdByteSize. The zero-copy segment compress/decompress overloads deliberately keep their raw long return (the count feeds straight into MemorySegment.asSlice); documented as such. New value types: - ZstdWindowLog (windowLog / windowLogMax), validated against the linked libzstd's accepted range, with AUTO (0) for "library chooses". - ZstdMagicVariant (writeSkippableFrame / ZstdSkippableContent.magicVariant), validated to 0..15. Also documents the earlier, undocumented decompress(int -> ZstdByteSize) change and the new decompress(byte[]) overload in CHANGELOG [Unreleased], and migrates all callers (unit, integration, smoke) plus adds tests for the two new types. Co-Authored-By: Claude Opus 4.8 --- .github/smoke/src/test/java/SmokeTest.java | 20 ++--- CHANGELOG.md | 25 ++++++ .../dfa1/zstd/it/ZstdInteropExtrasTest.java | 5 +- .../github/dfa1/zstd/ZstdCompressContext.java | 10 ++- .../dfa1/zstd/ZstdDecompressContext.java | 10 ++- .../io/github/dfa1/zstd/ZstdDictionary.java | 8 +- .../java/io/github/dfa1/zstd/ZstdFrame.java | 37 ++++----- .../io/github/dfa1/zstd/ZstdMagicVariant.java | 23 ++++++ .../dfa1/zstd/ZstdSkippableContent.java | 12 +-- .../io/github/dfa1/zstd/ZstdWindowLog.java | 32 ++++++++ .../github/dfa1/zstd/ZstdDictionaryTest.java | 16 ++-- .../io/github/dfa1/zstd/ZstdFrameTest.java | 24 +++--- .../dfa1/zstd/ZstdMagicVariantTest.java | 35 +++++++++ .../github/dfa1/zstd/ZstdParameterTest.java | 4 +- .../github/dfa1/zstd/ZstdWindowLogTest.java | 76 +++++++++++++++++++ 15 files changed, 272 insertions(+), 65 deletions(-) create mode 100644 zstd/src/main/java/io/github/dfa1/zstd/ZstdMagicVariant.java create mode 100644 zstd/src/main/java/io/github/dfa1/zstd/ZstdWindowLog.java create mode 100644 zstd/src/test/java/io/github/dfa1/zstd/ZstdMagicVariantTest.java create mode 100644 zstd/src/test/java/io/github/dfa1/zstd/ZstdWindowLogTest.java diff --git a/.github/smoke/src/test/java/SmokeTest.java b/.github/smoke/src/test/java/SmokeTest.java index 025ceec..aca3236 100644 --- a/.github/smoke/src/test/java/SmokeTest.java +++ b/.github/smoke/src/test/java/SmokeTest.java @@ -22,10 +22,12 @@ import io.github.dfa1.zstd.ZstdFrameProgression; import io.github.dfa1.zstd.ZstdFrameType; import io.github.dfa1.zstd.ZstdInputStream; +import io.github.dfa1.zstd.ZstdMagicVariant; import io.github.dfa1.zstd.ZstdOutputStream; import io.github.dfa1.zstd.ZstdResetDirective; import io.github.dfa1.zstd.ZstdSkippableContent; import io.github.dfa1.zstd.ZstdStreamResult; +import io.github.dfa1.zstd.ZstdWindowLog; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -168,14 +170,14 @@ void frameIntrospection() { check(ZstdFrame.isZstdFrame(compressed), "isZstdFrame(byte[]) false for a real frame"); check(!ZstdFrame.isZstdFrame("plain text, not zstd".getBytes(StandardCharsets.UTF_8)), "isZstdFrame(byte[]) true for non-zstd data"); - check(ZstdFrame.compressedSize(compressed) == compressed.length, "compressedSize(byte[]) mismatch"); + check(ZstdFrame.compressedSize(compressed).value() == compressed.length, "compressedSize(byte[]) mismatch"); check(ZstdFrame.decompressedSize(compressed).value() == original.length, "decompressedSize(byte[]) mismatch"); check(ZstdFrame.decompressedBound(compressed).value() >= original.length, "decompressedBound(byte[]) too small"); - check(ZstdFrame.decompressionMargin(compressed) >= 0, "decompressionMargin(byte[]) negative"); + check(ZstdFrame.decompressionMargin(compressed).value() >= 0, "decompressionMargin(byte[]) negative"); check(!ZstdFrame.dictId(compressed).isPresent(), "dictId(byte[]) expected NONE for a non-dictionary frame"); check(!ZstdFrame.isSkippableFrame(compressed), "isSkippableFrame(byte[]) true for a standard frame"); - long headerSize = ZstdFrame.headerSize(compressed); + long headerSize = ZstdFrame.headerSize(compressed).value(); check(headerSize > 0 && headerSize <= compressed.length, "headerSize(byte[]) out of range"); ZstdFrameHeader header = ZstdFrame.header(compressed); @@ -189,14 +191,14 @@ void frameIntrospection() { @Test void skippableFrames() { byte[] payload = "smoke-test skippable payload".getBytes(StandardCharsets.UTF_8); - byte[] frame = ZstdFrame.writeSkippableFrame(payload, 3); + byte[] frame = ZstdFrame.writeSkippableFrame(payload, new ZstdMagicVariant(3)); check(ZstdFrame.isSkippableFrame(frame), "isSkippableFrame(byte[]) false for a written skippable frame"); check(ZstdFrame.isZstdFrame(frame), "isZstdFrame(byte[]) should be true for a skippable frame too"); ZstdSkippableContent read = ZstdFrame.readSkippableFrame(frame); checkArrayEquals(read.content(), payload, "readSkippableFrame() content mismatch"); - check(read.magicVariant() == 3, "readSkippableFrame() magicVariant mismatch"); + check(read.magicVariant().value() == 3, "readSkippableFrame() magicVariant mismatch"); } @Test @@ -206,7 +208,7 @@ void compressContextAdvancedParameters() { cctx.level(new ZstdCompressionLevel(5)) .checksum(true) .longDistanceMatching(true) - .windowLog(20) + .windowLog(new ZstdWindowLog(20)) .parameter(ZstdCompressParameter.STRATEGY, 3); byte[] compressed = cctx.compress(original); @@ -224,10 +226,10 @@ void compressContextAdvancedParameters() { @Test void decompressContextAdvanced() { byte[] original = sampleText(); - try (ZstdCompressContext cctx = new ZstdCompressContext().windowLog(23); + try (ZstdCompressContext cctx = new ZstdCompressContext().windowLog(new ZstdWindowLog(23)); ZstdDecompressContext dctx = new ZstdDecompressContext()) { - dctx.windowLogMax(24); - byte[] restored = dctx.decompress(cctx.compress(original), original.length); + dctx.windowLogMax(new ZstdWindowLog(24)); + byte[] restored = dctx.decompress(cctx.compress(original), new ZstdByteSize(original.length)); checkArrayEquals(original, restored, "windowLogMax() decompress round-trip mismatch"); check(dctx.sizeOf().value() > 0, "dctx.sizeOf() not positive"); diff --git a/CHANGELOG.md b/CHANGELOG.md index b60c9a3..d3f4dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ git tags, which trigger publication to Maven Central. ## [Unreleased] +### Added +- `ZstdDecompressContext.decompress(byte[])` — decompresses a trusted frame using + the decompressed size stored in its header, mirroring the static + `Zstd.decompress(byte[])`. For untrusted input keep the bounded + `decompress(byte[], ZstdByteSize)` overload, now documented as the safe entry + point. +- `ZstdWindowLog` and `ZstdMagicVariant` value types, validated at construction — + the window log against the linked libzstd's accepted range (with `0` / + `ZstdWindowLog.AUTO` for "library chooses"), the magic variant to `0..15`. + ### Changed - **Breaking:** the compression-level bound queries `Zstd.maxCompressionLevel()`, `minCompressionLevel()`, and `defaultCompressionLevel()` are no longer public. @@ -17,6 +27,21 @@ git tags, which trigger publication to Maven Central. throws `ZstdException` (was `ArithmeticException`) when the size exceeds the maximum array length — the narrowing happens at the API boundary, so callers no longer catch a raw `ArithmeticException`. +- **Breaking:** `ZstdDecompressContext.decompress` now takes a `ZstdByteSize` + bound instead of a naked `int` (all three overloads: plain, `+ ZstdDictionary`, + `+ ZstdDecompressDictionary`), completing the `ZstdByteSize` migration. Wrap the + bound in `new ZstdByteSize(n)`. +- **Breaking:** the frame-size probes `ZstdFrame.compressedSize`, `headerSize`, + and `decompressionMargin` now return `ZstdByteSize` instead of `long`, and + `ZstdDictionary.size()`/`headerSize()` return `ZstdByteSize` instead of `int`, + matching the other size accessors — call `.value()` for the raw number. The + zero-copy segment `compress`/`decompress` overloads still return a raw `long` + by design (the count feeds straight into `MemorySegment.asSlice`). +- **Breaking:** `ZstdCompressContext.windowLog` and + `ZstdDecompressContext.windowLogMax` now take a `ZstdWindowLog`, and + `ZstdFrame.writeSkippableFrame` / `ZstdSkippableContent.magicVariant()` now use + `ZstdMagicVariant`. Wrap the raw `int` (`new ZstdWindowLog(n)` / + `new ZstdMagicVariant(n)`), or use `ZstdWindowLog.AUTO`. ## [0.11] - 2026-07-24 diff --git a/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdInteropExtrasTest.java b/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdInteropExtrasTest.java index 5a0d4e7..1349d6b 100644 --- a/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdInteropExtrasTest.java +++ b/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdInteropExtrasTest.java @@ -11,6 +11,7 @@ import io.github.dfa1.zstd.ZstdFrameHeader; import io.github.dfa1.zstd.ZstdFrameType; import io.github.dfa1.zstd.ZstdInputStream; +import io.github.dfa1.zstd.ZstdMagicVariant; import io.github.dfa1.zstd.ZstdOutputStream; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.Nested; @@ -128,7 +129,7 @@ void jniStreamSkipsJavaSkippableFrame() { // Given byte[] payload = "after the skippable frame ".repeat(1000).getBytes(StandardCharsets.UTF_8); byte[] meta = "sidecar-metadata".getBytes(StandardCharsets.UTF_8); - byte[] skippable = ZstdFrame.writeSkippableFrame(meta, 0); + byte[] skippable = ZstdFrame.writeSkippableFrame(meta, new ZstdMagicVariant(0)); byte[] real = Zstd.compress(payload, ZstdCompressionLevel.DEFAULT); // When @@ -142,7 +143,7 @@ void jniStreamSkipsJavaSkippableFrame() { void javaParsesItsOwnSkippableFrameHeader() { // Given byte[] meta = "sidecar".getBytes(StandardCharsets.UTF_8); - byte[] skippable = ZstdFrame.writeSkippableFrame(meta, 5); + byte[] skippable = ZstdFrame.writeSkippableFrame(meta, new ZstdMagicVariant(5)); // When ZstdFrameHeader header = ZstdFrame.header(skippable); diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java index cc88321..173a13b 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java @@ -85,8 +85,9 @@ public ZstdCompressContext longDistanceMatching(boolean enabled) { /// /// @param windowLog the base-2 log of the window size /// @return `this`, for chaining - public ZstdCompressContext windowLog(int windowLog) { - return parameter(ZstdCompressParameter.WINDOW_LOG, windowLog); + public ZstdCompressContext windowLog(ZstdWindowLog windowLog) { + Objects.requireNonNull(windowLog, "windowLog"); + return parameter(ZstdCompressParameter.WINDOW_LOG, windowLog.value()); } /// Resets this context so it can be reused for the next frame without the @@ -288,6 +289,11 @@ public byte[] compress(byte[] src, ZstdCompressDictionary dict) { /// /// Size `dst` with [Zstd#compressBound(ZstdByteSize)] to guarantee it fits. /// + /// Returns a raw `long` rather than a [ZstdByteSize]: on this zero-copy path + /// the byte count feeds straight into [MemorySegment#asSlice(long, long)], so + /// it is left unboxed by design to keep the fast path allocation-free. The + /// heap `byte[]` overloads, which size an array, return richer types. + /// /// @param dst the native destination buffer to write the frame into /// @param src the native source bytes to compress /// @return the number of bytes written into `dst` diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java index 0b2afc3..a9f2d34 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java @@ -37,8 +37,9 @@ public ZstdDecompressContext parameter(ZstdDecompressParameter parameter, int va /// /// @param windowLogMax the base-2 log of the maximum accepted window size /// @return `this`, for chaining - public ZstdDecompressContext windowLogMax(int windowLogMax) { - return parameter(ZstdDecompressParameter.WINDOW_LOG_MAX, windowLogMax); + public ZstdDecompressContext windowLogMax(ZstdWindowLog windowLogMax) { + Objects.requireNonNull(windowLogMax, "windowLogMax"); + return parameter(ZstdDecompressParameter.WINDOW_LOG_MAX, windowLogMax.value()); } /// Resets this context so it can be reused for the next frame without the @@ -247,6 +248,11 @@ public byte[] decompress(byte[] compressed, ZstdByteSize maxSize, ZstdDecompress /// Size `dst` to the decompressed length (read it from the frame with /// [Zstd#decompress(byte[])]'s header logic, or known out-of-band). /// + /// Returns a raw `long` rather than a [ZstdByteSize]: on this zero-copy path + /// the byte count feeds straight into [MemorySegment#asSlice(long, long)], so + /// it is left unboxed by design to keep the fast path allocation-free. The + /// heap `byte[]` overloads, which size an array, return richer types. + /// /// @param dst the native destination buffer to write the result into /// @param src the native source frame to decompress /// @return the number of bytes written into `dst` 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 102cb11..c138684 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java @@ -284,14 +284,14 @@ public ZstdDictionaryId id() { /// /// @return the header size in bytes /// @throws ZstdException if this is not a valid zstd dictionary - public int headerSize() { + public ZstdByteSize headerSize() { try (Arena arena = Arena.ofConfined()) { MemorySegment seg = Zstd.copyIn(arena, bytes); long size = (long) Bindings.ZDICT_GET_DICT_HEADER_SIZE.invokeExact(seg, (long) bytes.length); if (zdictIsError(size)) { throw new ZstdException("not a valid dictionary: " + zdictErrorName(size)); } - return Math.toIntExact(size); + return new ZstdByteSize(size); } catch (Throwable t) { throw NativeCall.rethrow(t); } @@ -307,8 +307,8 @@ public byte[] toByteArray() { /// The size of this dictionary. /// /// @return the dictionary size in bytes - public int size() { - return bytes.length; + public ZstdByteSize size() { + return new ZstdByteSize(bytes.length); } /// Digests this dictionary once for compression at `level`, ready to share by diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java index 2c60184..1f2a1df 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java @@ -2,6 +2,7 @@ import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; +import java.util.Objects; import static java.lang.foreign.ValueLayout.JAVA_INT; import static java.lang.foreign.ValueLayout.JAVA_LONG; @@ -37,9 +38,9 @@ public static boolean isZstdFrame(MemorySegment data) { /// @param data one or more concatenated zstd frames /// @return the byte length of the first frame /// @throws ZstdException if the input is not a valid frame - public static long compressedSize(byte[] data) { + public static ZstdByteSize compressedSize(byte[] data) { try (Arena arena = Arena.ofConfined()) { - return compressedSize(Zstd.copyIn(arena, data), data.length); + return new ZstdByteSize(compressedSize(Zstd.copyIn(arena, data), data.length)); } } @@ -48,8 +49,8 @@ public static long compressedSize(byte[] data) { /// @param data one or more concatenated zstd frames /// @return the byte length of the first frame /// @throws ZstdException if the input is not a valid frame - public static long compressedSize(MemorySegment data) { - return compressedSize(data, data.byteSize()); + public static ZstdByteSize compressedSize(MemorySegment data) { + return new ZstdByteSize(compressedSize(data, data.byteSize())); } /// Upper bound on the total decompressed size of all frames in `data`, @@ -114,9 +115,9 @@ public static ZstdByteSize decompressedSize(MemorySegment data) { /// @param data one or more concatenated zstd frames /// @return the in-place decompression margin in bytes /// @throws ZstdException if the input is not valid zstd data - public static long decompressionMargin(byte[] data) { + public static ZstdByteSize decompressionMargin(byte[] data) { try (Arena arena = Arena.ofConfined()) { - return decompressionMargin(Zstd.copyIn(arena, data), data.length); + return new ZstdByteSize(decompressionMargin(Zstd.copyIn(arena, data), data.length)); } } @@ -126,8 +127,8 @@ public static long decompressionMargin(byte[] data) { /// @param data one or more concatenated zstd frames /// @return the in-place decompression margin in bytes /// @throws ZstdException if the input is not valid zstd data - public static long decompressionMargin(MemorySegment data) { - return decompressionMargin(data, data.byteSize()); + public static ZstdByteSize decompressionMargin(MemorySegment data) { + return new ZstdByteSize(decompressionMargin(data, data.byteSize())); } /// Dictionary id recorded in the first frame's header. @@ -160,9 +161,9 @@ public static ZstdDictionaryId dictId(MemorySegment data) { /// @return the header size in bytes /// @throws ZstdException if `data` is too short to determine the header size, or /// is not a valid zstd frame - public static long headerSize(byte[] data) { + public static ZstdByteSize headerSize(byte[] data) { try (Arena arena = Arena.ofConfined()) { - return headerSize(Zstd.copyIn(arena, data), data.length); + return new ZstdByteSize(headerSize(Zstd.copyIn(arena, data), data.length)); } } @@ -173,8 +174,8 @@ public static long headerSize(byte[] data) { /// @return the header size in bytes /// @throws ZstdException if `data` is too short to determine the header size, or /// is not a valid zstd frame - public static long headerSize(MemorySegment data) { - return headerSize(data, data.byteSize()); + public static ZstdByteSize headerSize(MemorySegment data) { + return new ZstdByteSize(headerSize(data, data.byteSize())); } /// Parses the header of the first frame in `data` without decompressing it. @@ -219,22 +220,22 @@ public static boolean isSkippableFrame(MemorySegment data) { /// decoder skips over, letting you interleave it with compressed frames. /// /// @param content the user bytes to embed - /// @param magicVariant the variant 0..15 selecting one of the skippable magic numbers + /// @param magicVariant the variant selecting one of the skippable magic numbers /// @return the skippable frame bytes - /// @throws ZstdException if `magicVariant` is out of range - public static byte[] writeSkippableFrame(byte[] content, int magicVariant) { + public static byte[] writeSkippableFrame(byte[] content, ZstdMagicVariant magicVariant) { + Objects.requireNonNull(magicVariant, "magicVariant"); try (Arena arena = Arena.ofConfined()) { MemorySegment src = Zstd.copyIn(arena, content); long cap = content.length + 8L; MemorySegment dst = arena.allocate(cap); long written = NativeCall.checkReturnValue(() -> (long) Bindings.WRITE_SKIPPABLE_FRAME.invokeExact( - dst, cap, src, (long) content.length, magicVariant)); + dst, cap, src, (long) content.length, magicVariant.value())); return Zstd.copyOut(dst, written); } } /// Reads the content of a skippable frame produced by - /// [#writeSkippableFrame(byte[], int)]. + /// [#writeSkippableFrame(byte[], ZstdMagicVariant)]. /// /// @param frame a skippable frame /// @return the embedded content and its magic variant @@ -246,7 +247,7 @@ public static ZstdSkippableContent readSkippableFrame(byte[] frame) { MemorySegment dst = arena.allocate(Math.max(frame.length, 1)); long written = NativeCall.checkReturnValue(() -> (long) Bindings.READ_SKIPPABLE_FRAME.invokeExact( dst, (long) frame.length, magic, src, (long) frame.length)); - return new ZstdSkippableContent(Zstd.copyOut(dst, written), magic.get(JAVA_INT, 0)); + return new ZstdSkippableContent(Zstd.copyOut(dst, written), new ZstdMagicVariant(magic.get(JAVA_INT, 0))); } } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdMagicVariant.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdMagicVariant.java new file mode 100644 index 0000000..61c8ebb --- /dev/null +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdMagicVariant.java @@ -0,0 +1,23 @@ +package io.github.dfa1.zstd; + +/// A validated zstd skippable-frame magic variant — the low nibble `0..15` that +/// selects one of the sixteen skippable magic numbers (`0x184D2A50`..`0x184D2A5F`). +/// Replaces a naked `int` at the skippable-frame API boundary +/// ([ZstdFrame#writeSkippableFrame(byte[], ZstdMagicVariant)] and +/// [ZstdSkippableContent#magicVariant()]), rejecting an out-of-range selector in +/// Java rather than deferring to native code. +/// +/// @param value the variant selector, `0..15` +public record ZstdMagicVariant(int value) { + + private static final int MIN = 0; + private static final int MAX = 15; + + /// Validates `value` is in `0..15`. + public ZstdMagicVariant { + if (value < MIN || value > MAX) { + throw new IllegalArgumentException( + "magic variant " + value + " must be in [" + MIN + ", " + MAX + "]"); + } + } +} diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdSkippableContent.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdSkippableContent.java index 83a3a11..9ce9d18 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdSkippableContent.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdSkippableContent.java @@ -6,8 +6,8 @@ /// [ZstdFrame#readSkippableFrame(byte[])]. /// /// @param content the user bytes carried by the skippable frame -/// @param magicVariant the variant 0..15 the frame was written with -public record ZstdSkippableContent(byte[] content, int magicVariant) { +/// @param magicVariant the variant the frame was written with +public record ZstdSkippableContent(byte[] content, ZstdMagicVariant magicVariant) { /// Defensively copies `content` so the record owns its bytes and cannot be /// mutated through the array the caller passed in. @@ -30,8 +30,8 @@ public byte[] content() { /// @return `true` if `o` is a [ZstdSkippableContent] with equal content bytes and variant @Override public boolean equals(Object o) { - return o instanceof ZstdSkippableContent(byte[] otherContent, int otherVariant) - && magicVariant == otherVariant + return o instanceof ZstdSkippableContent(byte[] otherContent, ZstdMagicVariant otherVariant) + && magicVariant.equals(otherVariant) && Arrays.equals(content, otherContent); } @@ -41,7 +41,7 @@ public boolean equals(Object o) { /// @return the content-based hash code @Override public int hashCode() { - return 31 * Arrays.hashCode(content) + magicVariant; + return 31 * Arrays.hashCode(content) + magicVariant.hashCode(); } /// Short description carrying the payload length and variant rather than the @@ -51,6 +51,6 @@ public int hashCode() { @Override @SuppressWarnings("NullableProblems") // toString never returns null; we just don't pull in JB @NotNull public String toString() { - return "ZstdSkippableContent[content=" + content.length + " bytes, magicVariant=" + magicVariant + "]"; + return "ZstdSkippableContent[content=" + content.length + " bytes, magicVariant=" + magicVariant.value() + "]"; } } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdWindowLog.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdWindowLog.java new file mode 100644 index 0000000..c6917eb --- /dev/null +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdWindowLog.java @@ -0,0 +1,32 @@ +package io.github.dfa1.zstd; + +/// A validated zstd window-log — the base-2 logarithm of the back-reference +/// window size — replacing a naked `int` at every public API boundary that takes +/// one ([ZstdCompressContext#windowLog(ZstdWindowLog)] and +/// [ZstdDecompressContext#windowLogMax(ZstdWindowLog)]). +/// +/// A larger window trades memory for ratio. [#AUTO] (value `0`) leaves the choice +/// to the library — derived from the compression level on the encoder side, and +/// meaning "impose no extra limit" on the decoder side. Any other value must fall +/// within the range the linked libzstd accepts (the [ZstdBounds] of +/// [ZstdCompressParameter#WINDOW_LOG], which match the decoder's window limit on +/// supported platforms), validated in Java before it reaches native code. +/// +/// @param value the base-2 log of the window size, or `0` for [#AUTO] +public record ZstdWindowLog(int value) { + + // Queried once from the linked libzstd, fixed for the life of the process. + private static final int MIN_ACCEPTED = ZstdCompressParameter.WINDOW_LOG.bounds().lowerBound(); + private static final int MAX_ACCEPTED = ZstdCompressParameter.WINDOW_LOG.bounds().upperBound(); + + /// Let the library select the window size (value `0`). + public static final ZstdWindowLog AUTO = new ZstdWindowLog(0); + + /// Validates `value` is `0` ([#AUTO]) or within the linked libzstd's accepted range. + public ZstdWindowLog { + if (value != 0 && (value < MIN_ACCEPTED || value > MAX_ACCEPTED)) { + throw new IllegalArgumentException( + "windowLog " + value + " must be 0 or in [" + MIN_ACCEPTED + ", " + MAX_ACCEPTED + "]"); + } + } +} diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java index f5c788b..b1bb173 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java @@ -42,7 +42,7 @@ class CoverTraining { void fastCoverRoundTrips() { // Given a fast-COVER-trained dictionary ZstdDictionary dict = ZstdDictionary.trainFastCover(samples, ZstdByteSize.ofKiB(16)); - assertThat(dict.size()).isGreaterThan(0); + assertThat(dict.size().value()).isGreaterThan(0); // Then records round-trip and compress smaller than dictionaryless byte[] sample = samples.get(321); @@ -63,7 +63,7 @@ void fastCoverRoundTrips() { void coverRoundTrips() { // COVER is slower, so train on a subset to keep the test quick ZstdDictionary dict = ZstdDictionary.trainCover(samples.subList(0, 1000), ZstdByteSize.ofKiB(8)); - assertThat(dict.size()).isGreaterThan(0); + assertThat(dict.size().value()).isGreaterThan(0); byte[] sample = samples.get(5); try (ZstdCompressContext cctx = new ZstdCompressContext(); @@ -109,8 +109,8 @@ void finalizesRawContentIntoUsableDictionary() { ZstdDictionary.finalizeFrom(content, samples, ZstdByteSize.ofKiB(16), new ZstdCompressionLevel(0)); // Then it carries a header and round-trips a sample - assertThat(dict.size()).isGreaterThan(0); - assertThat(dict.headerSize()).isPositive(); + assertThat(dict.size().value()).isGreaterThan(0); + assertThat(dict.headerSize().value()).isPositive(); byte[] sample = samples.get(3); try (ZstdCompressContext cctx = new ZstdCompressContext(); @@ -122,8 +122,8 @@ void finalizesRawContentIntoUsableDictionary() { @Test void trainedDictionaryHasHeader() { - assertThat(sut.headerSize()).isPositive(); - assertThat(sut.headerSize()).isLessThanOrEqualTo(sut.size()); + assertThat(sut.headerSize().value()).isPositive(); + assertThat(sut.headerSize().value()).isLessThanOrEqualTo(sut.size().value()); } @Test @@ -145,8 +145,8 @@ class Training { @Test void producesNonEmptyDictionary() { // Then the trained dictionary has content matching its reported size - assertThat(sut.size()).isGreaterThan(0); - assertThat(sut.toByteArray()).hasSize(sut.size()); + assertThat(sut.size().value()).isGreaterThan(0); + assertThat(sut.toByteArray()).hasSize(sut.size().toArraySize()); } @Test diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java index d40307e..fac77cf 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java @@ -50,7 +50,7 @@ void equalsLengthForSingleFrame() { byte[] frame = Zstd.compress(PAYLOAD); // Then its compressed size is the whole buffer - assertThat(ZstdFrame.compressedSize(frame)).isEqualTo(frame.length); + assertThat(ZstdFrame.compressedSize(frame)).isEqualTo(new ZstdByteSize(frame.length)); } @Test @@ -63,7 +63,7 @@ void locatesFirstFrameBoundaryWhenConcatenated() throws Exception { both.write(second); // Then compressedSize reports where the second frame begins - assertThat(ZstdFrame.compressedSize(both.toByteArray())).isEqualTo(first.length); + assertThat(ZstdFrame.compressedSize(both.toByteArray())).isEqualTo(new ZstdByteSize(first.length)); } @Test @@ -172,7 +172,7 @@ void matchesTheParsedHeaderSize() { byte[] frame = Zstd.compress(PAYLOAD); // Then the light probe agrees with the fully parsed header - assertThat(ZstdFrame.headerSize(frame)).isEqualTo(ZstdFrame.header(frame).headerSize()); + assertThat(ZstdFrame.headerSize(frame)).isEqualTo(new ZstdByteSize(ZstdFrame.header(frame).headerSize())); } @Test @@ -219,7 +219,7 @@ void isPositiveForAValidFrame() { byte[] frame = Zstd.compress(PAYLOAD); // Then its in-place margin is a real, positive size - assertThat(ZstdFrame.decompressionMargin(frame)).isPositive(); + assertThat(ZstdFrame.decompressionMargin(frame).value()).isPositive(); } @Test @@ -235,7 +235,7 @@ void matchesThroughTheSegmentOverload() { void enablesInPlaceDecompression() { // Given a frame and a single buffer sized output + margin byte[] frame = Zstd.compress(PAYLOAD); - long margin = ZstdFrame.decompressionMargin(frame); + long margin = ZstdFrame.decompressionMargin(frame).value(); try (Arena arena = Arena.ofConfined(); ZstdDecompressContext dctx = new ZstdDecompressContext()) { MemorySegment buf = arena.allocate(PAYLOAD.length + margin); @@ -351,7 +351,7 @@ class Skippable { void roundTripsContentAndMagicVariant() { // Given user metadata wrapped in a skippable frame byte[] meta = "sidecar metadata".getBytes(StandardCharsets.UTF_8); - byte[] frame = ZstdFrame.writeSkippableFrame(meta, 7); + byte[] frame = ZstdFrame.writeSkippableFrame(meta, new ZstdMagicVariant(7)); // Then it is recognized as skippable and decodes back with its variant assertThat(ZstdFrame.isSkippableFrame(frame)).isTrue(); @@ -359,7 +359,7 @@ void roundTripsContentAndMagicVariant() { ZstdSkippableContent read = ZstdFrame.readSkippableFrame(frame); assertThat(read.content()).isEqualTo(meta); - assertThat(read.magicVariant()).isEqualTo(7); + assertThat(read.magicVariant()).isEqualTo(new ZstdMagicVariant(7)); } @Test @@ -371,7 +371,7 @@ void standardFrameIsNotSkippable() { void defensivelyCopiesContentInAndOut() { // Given a backing array wrapped in a skippable-content value byte[] backing = "metadata".getBytes(StandardCharsets.UTF_8); - ZstdSkippableContent content = new ZstdSkippableContent(backing, 2); + ZstdSkippableContent content = new ZstdSkippableContent(backing, new ZstdMagicVariant(2)); // When the source array and a value returned by the accessor are mutated backing[0] = 'X'; @@ -384,9 +384,9 @@ void defensivelyCopiesContentInAndOut() { @Test void contentHasValueEqualityOverTheBytesNotArrayIdentity() { // Given two separately built payloads with the same bytes and variant, and one differing - ZstdSkippableContent a = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), 3); - ZstdSkippableContent b = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), 3); - ZstdSkippableContent differentVariant = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), 4); + ZstdSkippableContent a = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), new ZstdMagicVariant(3)); + ZstdSkippableContent b = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), new ZstdMagicVariant(3)); + ZstdSkippableContent differentVariant = new ZstdSkippableContent("meta".getBytes(StandardCharsets.UTF_8), new ZstdMagicVariant(4)); // When compared by value and rendered as text boolean sameBytesEqual = a.equals(b); @@ -462,7 +462,7 @@ void mirrorTheByteArrayOverloadsForANativeFrame() { @Test void recognizesASkippableNativeFrame() { // Given a skippable frame in a native segment - byte[] frame = ZstdFrame.writeSkippableFrame("meta".getBytes(StandardCharsets.UTF_8), 5); + byte[] frame = ZstdFrame.writeSkippableFrame("meta".getBytes(StandardCharsets.UTF_8), new ZstdMagicVariant(5)); try (Arena arena = Arena.ofConfined()) { MemorySegment seg = Zstd.copyIn(arena, frame); diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdMagicVariantTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMagicVariantTest.java new file mode 100644 index 0000000..1d3be77 --- /dev/null +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMagicVariantTest.java @@ -0,0 +1,35 @@ +package io.github.dfa1.zstd; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZstdMagicVariantTest { + + @ParameterizedTest + @ValueSource(ints = {0, 1, 7, 15}) + void acceptsVariantsInRange(int value) { + // Given a variant selector in 0..15 + // When wrapped + ZstdMagicVariant sut = new ZstdMagicVariant(value); + + // Then it holds that value + assertThat(sut.value()).isEqualTo(value); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 16, 100}) + void rejectsVariantsOutOfRange(int value) { + // Given a variant selector outside 0..15 + // When wrapped + ThrowingCallable result = () -> new ZstdMagicVariant(value); + + // Then it is rejected before reaching native code + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(String.valueOf(value)); + } +} 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 917cdca..5b5e761 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java @@ -70,7 +70,7 @@ void longDistanceMatchingRoundTrips() { @Test void explicitWindowLogRoundTrips() { byte[] frame; - try (ZstdCompressContext ctx = new ZstdCompressContext().windowLog(24)) { + try (ZstdCompressContext ctx = new ZstdCompressContext().windowLog(new ZstdWindowLog(24))) { frame = ctx.compress(PAYLOAD); } assertThat(Zstd.decompress(frame)).isEqualTo(PAYLOAD); @@ -127,7 +127,7 @@ class Decompress { void windowLogMaxIsAccepted() { // Given a decompressor configured with a raised window limit byte[] frame = Zstd.compress(PAYLOAD); - try (ZstdDecompressContext ctx = new ZstdDecompressContext().windowLogMax(31)) { + try (ZstdDecompressContext ctx = new ZstdDecompressContext().windowLogMax(new ZstdWindowLog(31))) { // Then normal frames still decode assertThat(ctx.decompress(frame, new ZstdByteSize(PAYLOAD.length))).isEqualTo(PAYLOAD); } diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdWindowLogTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdWindowLogTest.java new file mode 100644 index 0000000..3bb5c9f --- /dev/null +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdWindowLogTest.java @@ -0,0 +1,76 @@ +package io.github.dfa1.zstd; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZstdWindowLogTest { + + private static final ZstdBounds BOUNDS = ZstdCompressParameter.WINDOW_LOG.bounds(); + + @Nested + class Construction { + + @Test + void autoIsZero() { + // Then AUTO wraps 0, the library-chooses sentinel + assertThat(ZstdWindowLog.AUTO.value()).isZero(); + } + + @Test + void acceptsAnInRangeValue() { + // Given a window log at the low end of the accepted range + int inRange = BOUNDS.lowerBound(); + + // When wrapped + ZstdWindowLog sut = new ZstdWindowLog(inRange); + + // Then it holds that value + assertThat(sut.value()).isEqualTo(inRange); + } + + @Test + void zeroEqualsAuto() { + // When zero is wrapped explicitly + ZstdWindowLog sut = new ZstdWindowLog(0); + + // Then it equals AUTO + assertThat(sut).isEqualTo(ZstdWindowLog.AUTO); + } + } + + @Nested + class Validation { + + @Test + void rejectsOneBelowTheMinimum() { + // Given a non-zero value one below the accepted minimum + int belowMin = BOUNDS.lowerBound() - 1; + + // When wrapped + ThrowingCallable result = () -> new ZstdWindowLog(belowMin); + + // Then it is rejected before reaching native code + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(String.valueOf(belowMin)); + } + + @Test + void rejectsOneAboveTheMaximum() { + // Given a value one above the accepted maximum + int aboveMax = BOUNDS.upperBound() + 1; + + // When wrapped + ThrowingCallable result = () -> new ZstdWindowLog(aboveMax); + + // Then it is rejected before reaching native code + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(String.valueOf(aboveMax)); + } + } +} From fb1d9f4c00f3cd6b265adb3400afe01b27c32eba Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Sat, 25 Jul 2026 13:26:48 +0200 Subject: [PATCH 2/2] refactor: replace String/int version accessors with ZstdVersion Unifies Zstd.version() (was String "1.5.7") and Zstd.versionNumber() (was int 10507) into a single comparable ZstdVersion(major, minor, patch) value type: - number() gives the packed MAJOR*10000+MINOR*100+PATCH form - toString() gives the "x.y.z" string - isAtLeast(int, int, int) / Comparable enable feature-gating against the linked libzstd Zstd.version() now returns ZstdVersion and is decoded from ZSTD_versionNumber, so the ZSTD_versionString binding is dropped as dead. Migrates callers (unit, smoke), adds ZstdVersionTest, updates docs and CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- .github/smoke/src/test/java/SmokeTest.java | 4 +- CHANGELOG.md | 7 ++ docs/supported.md | 2 +- .../java/io/github/dfa1/zstd/Bindings.java | 4 - .../main/java/io/github/dfa1/zstd/Zstd.java | 25 ++---- .../java/io/github/dfa1/zstd/ZstdVersion.java | 77 ++++++++++++++++++ .../java/io/github/dfa1/zstd/ZstdTest.java | 19 ++--- .../io/github/dfa1/zstd/ZstdVersionTest.java | 79 +++++++++++++++++++ 8 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 zstd/src/main/java/io/github/dfa1/zstd/ZstdVersion.java create mode 100644 zstd/src/test/java/io/github/dfa1/zstd/ZstdVersionTest.java diff --git a/.github/smoke/src/test/java/SmokeTest.java b/.github/smoke/src/test/java/SmokeTest.java index aca3236..a297c0e 100644 --- a/.github/smoke/src/test/java/SmokeTest.java +++ b/.github/smoke/src/test/java/SmokeTest.java @@ -76,8 +76,8 @@ static void trainDictionary() { @Test void versionAndSizing() { - check(!Zstd.version().isBlank(), "version() returned blank"); - check(Zstd.versionNumber() > 0, "versionNumber() not positive"); + check(!Zstd.version().toString().isBlank(), "version() returned blank"); + check(Zstd.version().number() > 0, "version().number() not positive"); int min = ZstdCompressionLevel.FASTEST.value(); int max = ZstdCompressionLevel.MAX.value(); diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f4dc1..0d1fada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ git tags, which trigger publication to Maven Central. - `ZstdWindowLog` and `ZstdMagicVariant` value types, validated at construction — the window log against the linked libzstd's accepted range (with `0` / `ZstdWindowLog.AUTO` for "library chooses"), the magic variant to `0..15`. +- `ZstdVersion` value type — a comparable `(major, minor, patch)` with `number()` + (the packed form) and `toString()` (`x.y.z`), plus `isAtLeast(int, int, int)` + for feature-gating against the linked libzstd. ### Changed - **Breaking:** the compression-level bound queries `Zstd.maxCompressionLevel()`, @@ -42,6 +45,10 @@ git tags, which trigger publication to Maven Central. `ZstdFrame.writeSkippableFrame` / `ZstdSkippableContent.magicVariant()` now use `ZstdMagicVariant`. Wrap the raw `int` (`new ZstdWindowLog(n)` / `new ZstdMagicVariant(n)`), or use `ZstdWindowLog.AUTO`. +- **Breaking:** `Zstd.version()` now returns a `ZstdVersion` instead of a + `String`, and `Zstd.versionNumber()` is removed. Use + `Zstd.version().toString()` for the `x.y.z` string and + `Zstd.version().number()` for the packed number. ## [0.11] - 2026-07-24 diff --git a/docs/supported.md b/docs/supported.md index 3345199..12d6b44 100644 --- a/docs/supported.md +++ b/docs/supported.md @@ -46,7 +46,7 @@ rather than the deprecated `ZSTD_getDecompressedSize`. |---|---| | `ZSTD_compress`, `ZSTD_decompress`, `ZSTD_compressBound` | `Zstd.compress` / `decompress` / `compressBound` | | `ZSTD_maxCLevel`, `ZSTD_minCLevel`, `ZSTD_defaultCLevel` | `ZstdCompressionLevel.MAX` / `FASTEST` / `DEFAULT` | -| `ZSTD_versionNumber`, `ZSTD_versionString` | `Zstd.version` | +| `ZSTD_versionNumber`, `ZSTD_versionString` | `Zstd.version` (returns `ZstdVersion`) | | `ZSTD_isError`, `ZSTD_getErrorName` | internal error mapping in `Zstd` | | `ZSTD_getFrameContentSize` | `Zstd.decompress(byte[])`, `Zstd.decompressedSize` | | `ZSTD_createCCtx`, `ZSTD_freeCCtx`, `ZSTD_compressCCtx` | `ZstdCompressContext` | diff --git a/zstd/src/main/java/io/github/dfa1/zstd/Bindings.java b/zstd/src/main/java/io/github/dfa1/zstd/Bindings.java index 8bd41f8..09073c1 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/Bindings.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/Bindings.java @@ -122,10 +122,6 @@ final class Bindings { static final MethodHandle VERSION_NUMBER = NativeLibrary.lookup("ZSTD_versionNumber", FunctionDescriptor.of(JAVA_INT)); - // const char* ZSTD_versionString(void) - static final MethodHandle VERSION_STRING = - NativeLibrary.lookup("ZSTD_versionString", FunctionDescriptor.of(ADDRESS)); - // --- reusable contexts --- // ZSTD_CCtx* ZSTD_createCCtx(void) 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 cebe739..3c3a9b9 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java @@ -2,7 +2,6 @@ import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; -import java.nio.charset.StandardCharsets; import java.util.Objects; import static java.lang.foreign.ValueLayout.JAVA_BYTE; @@ -222,26 +221,16 @@ public static ZstdByteSize estimateDecompressDictSize(ZstdByteSize dictSize) { } } - /// Runtime zstd version, e.g. `"1.6.0"`. + /// Runtime version of the linked zstd library, e.g. `1.6.0`. /// - /// @return the linked zstd library version as an `x.y.z` string - @SuppressWarnings("restricted") // reinterpret needed to read a C string of unknown length - public static String version() { - try { - MemorySegment p = (MemorySegment) Bindings.VERSION_STRING.invokeExact(); - return p.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII); - } catch (Throwable t) { - throw NativeCall.rethrow(t); - } - } - - /// Runtime zstd version as a single number for programmatic comparison, - /// encoded `MAJOR * 10000 + MINOR * 100 + PATCH` — e.g. `10507` for `1.5.7`. + /// Read [ZstdVersion#toString()] for the `x.y.z` string, [ZstdVersion#number()] + /// for the packed number, or compare it (e.g. [ZstdVersion#isAtLeast(int, int, int)]) + /// to feature-gate against the runtime library. /// - /// @return the linked zstd library version number - public static int versionNumber() { + /// @return the linked zstd library version + public static ZstdVersion version() { try { - return (int) Bindings.VERSION_NUMBER.invokeExact(); + return ZstdVersion.ofNumber((int) Bindings.VERSION_NUMBER.invokeExact()); } catch (Throwable t) { throw NativeCall.rethrow(t); } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdVersion.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdVersion.java new file mode 100644 index 0000000..289588f --- /dev/null +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdVersion.java @@ -0,0 +1,77 @@ +package io.github.dfa1.zstd; + +/// The semantic version of the linked libzstd, replacing the split +/// `String`/`int` version accessors with one comparable value. +/// +/// zstd encodes its version two ways — a `"MAJOR.MINOR.PATCH"` string and a +/// packed `MAJOR * 10000 + MINOR * 100 + PATCH` number. This type carries both: +/// the components directly, [#number()] for the packed form, and [#toString()] +/// for the string. Being [Comparable] (and via [#isAtLeast(int, int, int)]), it +/// lets callers feature-gate against the runtime library, e.g. +/// `Zstd.version().isAtLeast(1, 5, 0)`. +/// +/// @param major the major version component +/// @param minor the minor version component +/// @param patch the patch version component +public record ZstdVersion(int major, int minor, int patch) implements Comparable { + + /// Validates the components are non-negative. + public ZstdVersion { + if (major < 0 || minor < 0 || patch < 0) { + throw new IllegalArgumentException("version " + major + "." + minor + "." + patch + " must not be negative"); + } + } + + /// Decodes zstd's packed `MAJOR * 10000 + MINOR * 100 + PATCH` version number. + /// + /// @param number the packed version number, as `ZSTD_versionNumber` returns it + /// @return the decoded version + static ZstdVersion ofNumber(int number) { + return new ZstdVersion(number / 10000, (number / 100) % 100, number % 100); + } + + /// The packed version number, `major * 10000 + minor * 100 + patch` — zstd's + /// own `ZSTD_versionNumber` encoding. + /// + /// @return the packed version number + public int number() { + return major * 10000 + minor * 100 + patch; + } + + /// Whether this version is at least `major.minor.patch`, for feature-gating + /// against the linked libzstd. + /// + /// @param major the minimum major version + /// @param minor the minimum minor version + /// @param patch the minimum patch version + /// @return `true` if this version is greater than or equal to `major.minor.patch` + public boolean isAtLeast(int major, int minor, int patch) { + return compareTo(new ZstdVersion(major, minor, patch)) >= 0; + } + + /// Orders by `major`, then `minor`, then `patch`. + /// + /// @param other the version to compare with + /// @return a negative, zero, or positive value as this version is less than, + /// equal to, or greater than `other` + @Override + public int compareTo(ZstdVersion other) { + int byMajor = Integer.compare(major, other.major); + if (byMajor != 0) { + return byMajor; + } + int byMinor = Integer.compare(minor, other.minor); + if (byMinor != 0) { + return byMinor; + } + return Integer.compare(patch, other.patch); + } + + /// The `"MAJOR.MINOR.PATCH"` string, matching `ZSTD_versionString`. + /// + /// @return the version as an `x.y.z` string + @Override + public String toString() { + return major + "." + minor + "." + patch; + } +} diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java index eff6f76..68e6ea4 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java @@ -155,23 +155,14 @@ class Metadata { @Test void reportsSemanticVersion() { - // When the runtime version is read / Then it is an x.y.z string - assertThat(Zstd.version()).matches("\\d+\\.\\d+\\.\\d+"); + // When the runtime version is read / Then it renders as an x.y.z string + assertThat(Zstd.version().toString()).matches("\\d+\\.\\d+\\.\\d+"); } @Test - void reportsVersionNumberConsistentWithTheString() { - // Given the x.y.z version string split into its parts - String[] parts = Zstd.version().split("\\."); - int expected = Integer.parseInt(parts[0]) * 10000 - + Integer.parseInt(parts[1]) * 100 - + Integer.parseInt(parts[2]); - - // When the numeric version is read - int number = Zstd.versionNumber(); - - // Then it encodes MAJOR * 10000 + MINOR * 100 + PATCH - assertThat(number).isEqualTo(expected); + void reportsAVersionAtLeastOne() { + // Then the linked library is at least 1.0.0 (zstd has been 1.x for years) + assertThat(Zstd.version().isAtLeast(1, 0, 0)).isTrue(); } } diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdVersionTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdVersionTest.java new file mode 100644 index 0000000..9a17254 --- /dev/null +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdVersionTest.java @@ -0,0 +1,79 @@ +package io.github.dfa1.zstd; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZstdVersionTest { + + @Nested + class Encoding { + + @Test + void decodesThePackedNumberIntoComponents() { + // Given zstd's packed number for 1.5.7 + // When decoded + ZstdVersion sut = ZstdVersion.ofNumber(10507); + + // Then the components are recovered + assertThat(sut).isEqualTo(new ZstdVersion(1, 5, 7)); + } + + @Test + void reEncodesToThePackedNumber() { + // Given a version + ZstdVersion sut = new ZstdVersion(1, 5, 7); + + // Then number() is MAJOR * 10000 + MINOR * 100 + PATCH + assertThat(sut.number()).isEqualTo(10507); + } + + @Test + void rendersAsAnXYZString() { + // Given a version + ZstdVersion sut = new ZstdVersion(1, 6, 0); + + // Then it renders as x.y.z + assertThat(sut).hasToString("1.6.0"); + } + } + + @Nested + class Ordering { + + @Test + void ordersByMajorThenMinorThenPatch() { + // Then a later patch/minor/major compares greater + assertThat(new ZstdVersion(1, 5, 7)).isGreaterThan(new ZstdVersion(1, 5, 6)); + assertThat(new ZstdVersion(1, 6, 0)).isGreaterThan(new ZstdVersion(1, 5, 9)); + assertThat(new ZstdVersion(2, 0, 0)).isGreaterThan(new ZstdVersion(1, 9, 9)); + } + + @Test + void isAtLeastIsInclusiveOfTheBound() { + // Given a version + ZstdVersion sut = new ZstdVersion(1, 5, 7); + + // Then isAtLeast holds at and below the version, not above + assertThat(sut.isAtLeast(1, 5, 7)).isTrue(); + assertThat(sut.isAtLeast(1, 5, 0)).isTrue(); + assertThat(sut.isAtLeast(1, 6, 0)).isFalse(); + } + } + + @Nested + class Validation { + + @Test + void rejectsANegativeComponent() { + // When a negative patch is wrapped + ThrowingCallable result = () -> new ZstdVersion(1, 5, -1); + + // Then it is rejected + assertThatThrownBy(result).isInstanceOf(IllegalArgumentException.class); + } + } +}