From b4773df2e64408e193f926e442c3a9d861f680f6 Mon Sep 17 00:00:00 2001 From: Davide Angelocola Date: Wed, 22 Jul 2026 07:54:25 +0200 Subject: [PATCH] feat: replace naked int/long byte sizes with ZstdByteSize Introduces ZstdByteSize, a record wrapping a byte size/count as a validated non-negative long, so that holding one guarantees validity instead of leaving callers to wonder whether a raw long from the library still needs checking. Provides toIntExact() for callers that must narrow to an int because the result ends up in a byte[] (throws ArithmeticException past Integer.MAX_VALUE, matching Math.toIntExact), and ofKiB(long)/ofMiB(long) factories for common buffer sizes instead of spelling out arithmetic like `new ZstdByteSize(16 * 1024)`. Replaces every naked int/long size parameter, and every long return value that represents a byte count, across the public API: Zstd.compressBound, Zstd.estimateCompressContextSize/estimateDecompressContextSize/ estimateCompressDictSize/estimateDecompressDictSize, Zstd.decompressedSize(MemorySegment), ZstdDictionary.train/trainCover/ trainFastCover/finalizeFrom, ZstdOutputStream.withPledgedSize, ZstdFrame.decompressedSize/decompressedBound, and sizeOf() on ZstdCompressContext/ZstdDecompressContext/ZstdCompressStream/ ZstdDecompressStream/ZstdCompressDictionary/ZstdDecompressDictionary. Centralizes interpretation of zstd's raw frame-content-size results as ZstdByteSize factories rather than scattered sentinel checks: - ZstdByteSize.fromFrameContentSize(long) turns the ZSTD_getFrameContentSize/ZSTD_findDecompressedSize/ZSTD_decompressBound sentinels (and any other negative reading, i.e. an unsigned value >= 2^63) into a ZstdException instead of a bogus negative size or the wrong exception type. - ZstdByteSize.fromFrameHeaderContentSize(long) backs ZstdFrameHeader.contentSize(), now Optional instead of OptionalLong. The CONTENTSIZE_UNKNOWN/CONTENTSIZE_ERROR sentinel constants move from Zstd to become a private implementation detail of ZstdByteSize. Breaking change; the library is pre-1.0 so no compatibility shims are added. Closes #96. --- .github/smoke/src/test/java/SmokeTest.java | 55 +++---- CHANGELOG.md | 27 ++++ README.md | 2 +- .../dfa1/zstd/bench/CompressBenchmark.java | 5 +- .../dfa1/zstd/bench/DecompressBenchmark.java | 2 + .../zstd/bench/GoldenCorpusBenchmark.java | 3 +- .../bench/MultiThreadCompressBenchmark.java | 5 +- docs/how-to.md | 10 +- docs/tutorial.md | 4 +- .../github/dfa1/zstd/it/GoldenCorpusTest.java | 3 +- .../dfa1/zstd/it/ZstdInteropExtrasTest.java | 5 +- .../dfa1/zstd/it/ZstdJniInteropTest.java | 7 +- .../main/java/io/github/dfa1/zstd/Zstd.java | 93 +++++------ .../io/github/dfa1/zstd/ZstdByteSize.java | 94 ++++++++++++ .../github/dfa1/zstd/ZstdCompressContext.java | 30 ++-- .../dfa1/zstd/ZstdCompressDictionary.java | 4 +- .../github/dfa1/zstd/ZstdCompressStream.java | 4 +- .../dfa1/zstd/ZstdDecompressContext.java | 12 +- .../dfa1/zstd/ZstdDecompressDictionary.java | 4 +- .../dfa1/zstd/ZstdDecompressStream.java | 4 +- .../io/github/dfa1/zstd/ZstdDictionary.java | 35 +++-- .../java/io/github/dfa1/zstd/ZstdFrame.java | 16 +- .../io/github/dfa1/zstd/ZstdFrameHeader.java | 8 +- .../io/github/dfa1/zstd/ZstdOutputStream.java | 4 +- .../io/github/dfa1/zstd/RefPrefixTest.java | 8 +- .../io/github/dfa1/zstd/ZstdByteSizeTest.java | 145 ++++++++++++++++++ .../github/dfa1/zstd/ZstdDictionaryTest.java | 18 +-- .../io/github/dfa1/zstd/ZstdErrorTest.java | 6 +- .../io/github/dfa1/zstd/ZstdFrameTest.java | 8 +- .../io/github/dfa1/zstd/ZstdMemoryTest.java | 27 ++-- .../github/dfa1/zstd/ZstdMultithreadTest.java | 10 +- .../github/dfa1/zstd/ZstdParameterTest.java | 2 +- .../dfa1/zstd/ZstdSegmentStreamTest.java | 6 +- .../io/github/dfa1/zstd/ZstdSegmentTest.java | 8 +- .../io/github/dfa1/zstd/ZstdStreamTest.java | 8 +- .../java/io/github/dfa1/zstd/ZstdTest.java | 25 ++- .../io/github/dfa1/zstd/ZstdTestSupport.java | 2 +- 37 files changed, 496 insertions(+), 213 deletions(-) create mode 100644 zstd/src/main/java/io/github/dfa1/zstd/ZstdByteSize.java create mode 100644 zstd/src/test/java/io/github/dfa1/zstd/ZstdByteSizeTest.java diff --git a/.github/smoke/src/test/java/SmokeTest.java b/.github/smoke/src/test/java/SmokeTest.java index 32534a4..9e6a08e 100644 --- a/.github/smoke/src/test/java/SmokeTest.java +++ b/.github/smoke/src/test/java/SmokeTest.java @@ -3,6 +3,7 @@ // workflow can pin the released version and the matrix's native jar. import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressDictionary; import io.github.dfa1.zstd.ZstdCompressionLevel; @@ -67,7 +68,7 @@ class SmokeTest { @BeforeAll static void trainDictionary() { List samples = jsonSamples(); - dict = ZstdDictionary.train(samples, 8 * 1024); + dict = ZstdDictionary.train(samples, ZstdByteSize.ofKiB(8)); message = samples.get(7); } @@ -82,13 +83,13 @@ void versionAndSizing() { check(min < max, "minCompressionLevel() >= maxCompressionLevel()"); check(def >= min && def <= max, "defaultCompressionLevel() out of [min,max]"); - check(Zstd.compressBound(1000) >= 1000, "compressBound() below input size"); - check(Zstd.estimateCompressContextSize(ZstdCompressionLevel.DEFAULT) > 0, + check(Zstd.compressBound(new ZstdByteSize(1000)).value() >= 1000, "compressBound() below input size"); + check(Zstd.estimateCompressContextSize(ZstdCompressionLevel.DEFAULT).value() > 0, "estimateCompressContextSize() not positive"); - check(Zstd.estimateDecompressContextSize() > 0, "estimateDecompressContextSize() not positive"); - check(Zstd.estimateCompressDictSize(4096, ZstdCompressionLevel.DEFAULT) > 0, + check(Zstd.estimateDecompressContextSize().value() > 0, "estimateDecompressContextSize() not positive"); + check(Zstd.estimateCompressDictSize(new ZstdByteSize(4096), ZstdCompressionLevel.DEFAULT).value() > 0, "estimateCompressDictSize() not positive"); - check(Zstd.estimateDecompressDictSize(4096) > 0, "estimateDecompressDictSize() not positive"); + check(Zstd.estimateDecompressDictSize(new ZstdByteSize(4096)).value() > 0, "estimateDecompressDictSize() not positive"); } @Test @@ -108,11 +109,11 @@ void explicitBoundDecompress() { byte[] original = sampleText(); byte[] compressed = Zstd.compress(original); - byte[] restored = Zstd.decompress(compressed, original.length); + byte[] restored = Zstd.decompress(compressed, new ZstdByteSize(original.length)); checkArrayEquals(original, restored, "decompress(byte[], maxSize) mismatch"); try { - Zstd.decompress(compressed, 1); + Zstd.decompress(compressed, new ZstdByteSize(1)); throw new AssertionError("expected ZstdException for an undersized maxSize on " + PLATFORM); } catch (ZstdException expected) { check(expected.code() == ZstdErrorCode.DST_SIZE_TOO_SMALL, @@ -126,7 +127,7 @@ void zeroCopyFrameSize() { byte[] compressed = Zstd.compress(original); try (Arena arena = Arena.ofConfined()) { MemorySegment frame = toNative(arena, compressed); - check(Zstd.decompressedSize(frame) == original.length, "decompressedSize(MemorySegment) mismatch"); + check(Zstd.decompressedSize(frame).value() == original.length, "decompressedSize(MemorySegment) mismatch"); } } @@ -142,7 +143,7 @@ void corruptAndInvalidInput() { // payload happens to compress on a given platform. corrupted[corrupted.length - 1] ^= 0xFF; try { - Zstd.decompress(corrupted, original.length); + Zstd.decompress(corrupted, new ZstdByteSize(original.length)); throw new AssertionError("expected ZstdException for a corrupted checksum on " + PLATFORM); } catch (ZstdException expected) { check(expected.code() == ZstdErrorCode.CHECKSUM_WRONG, @@ -168,8 +169,8 @@ void frameIntrospection() { 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.decompressedSize(compressed) == original.length, "decompressedSize(byte[]) mismatch"); - check(ZstdFrame.decompressedBound(compressed) >= original.length, "decompressedBound(byte[]) too small"); + 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.dictId(compressed).isPresent(), "dictId(byte[]) expected NONE for a non-dictionary frame"); check(!ZstdFrame.isSkippableFrame(compressed), "isSkippableFrame(byte[]) true for a standard frame"); @@ -178,7 +179,7 @@ void frameIntrospection() { check(headerSize > 0 && headerSize <= compressed.length, "headerSize(byte[]) out of range"); ZstdFrameHeader header = ZstdFrame.header(compressed); - check(header.contentSize().isPresent() && header.contentSize().getAsLong() == original.length, + check(header.contentSize().isPresent() && header.contentSize().get().value() == original.length, "header(byte[]).contentSize() mismatch"); check(header.frameType() == ZstdFrameType.STANDARD, "header(byte[]).frameType() expected STANDARD"); check(header.headerSize() == headerSize, "header(byte[]).headerSize() disagreed with headerSize(byte[])"); @@ -209,13 +210,13 @@ void compressContextAdvancedParameters() { .parameter(ZstdCompressParameter.STRATEGY, 3); byte[] compressed = cctx.compress(original); - checkArrayEquals(original, Zstd.decompress(compressed, original.length), + checkArrayEquals(original, Zstd.decompress(compressed, new ZstdByteSize(original.length)), "advanced-parameter round-trip mismatch"); - check(cctx.sizeOf() > 0, "cctx.sizeOf() not positive"); + check(cctx.sizeOf().value() > 0, "cctx.sizeOf() not positive"); cctx.reset(ZstdResetDirective.SESSION_AND_PARAMETERS); byte[] afterReset = cctx.level(new ZstdCompressionLevel(3)).compress(original); - checkArrayEquals(original, Zstd.decompress(afterReset, original.length), + checkArrayEquals(original, Zstd.decompress(afterReset, new ZstdByteSize(original.length)), "compress after SESSION_AND_PARAMETERS reset mismatch"); } } @@ -228,7 +229,7 @@ void decompressContextAdvanced() { dctx.windowLogMax(24); byte[] restored = dctx.decompress(cctx.compress(original), original.length); checkArrayEquals(original, restored, "windowLogMax() decompress round-trip mismatch"); - check(dctx.sizeOf() > 0, "dctx.sizeOf() not positive"); + check(dctx.sizeOf().value() > 0, "dctx.sizeOf() not positive"); dctx.reset(ZstdResetDirective.PARAMETERS); dctx.parameter(ZstdDecompressParameter.WINDOW_LOG_MAX, 24); @@ -269,7 +270,7 @@ void multiThreadRoundTrip() { try (ZstdCompressContext cctx = new ZstdCompressContext()) { cctx.parameter(ZstdCompressParameter.NB_WORKERS, 2); byte[] compressed = cctx.compress(original); - checkArrayEquals(original, Zstd.decompress(compressed, original.length), + checkArrayEquals(original, Zstd.decompress(compressed, new ZstdByteSize(original.length)), "multithreaded (NB_WORKERS=2) round-trip mismatch"); } } @@ -293,8 +294,8 @@ void digestedDictionaries() { byte[] compressed = cctx.compress(message, cdict); byte[] restored = dctx.decompress(compressed, message.length, ddict); checkArrayEquals(message, restored, "digested-dictionary round-trip mismatch"); - check(cdict.sizeOf() > 0, "ZstdCompressDictionary.sizeOf() not positive"); - check(ddict.sizeOf() > 0, "ZstdDecompressDictionary.sizeOf() not positive"); + check(cdict.sizeOf().value() > 0, "ZstdCompressDictionary.sizeOf() not positive"); + check(ddict.sizeOf().value() > 0, "ZstdDecompressDictionary.sizeOf() not positive"); } } @@ -306,7 +307,7 @@ void zeroCopyContextCompression() { byte[] original = sampleText(); MemorySegment src = toNative(arena, original); - MemorySegment dst = arena.allocate(Zstd.compressBound(src.byteSize())); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(src.byteSize())).value()); long written = cctx.compress(dst, src); MemorySegment restored = arena.allocate(original.length); long read = dctx.decompress(restored, dst.asSlice(0, written)); @@ -318,18 +319,18 @@ void zeroCopyContextCompression() { @Test void streamingZeroCopy() { try (ZstdCompressStream cs = new ZstdCompressStream()) { - check(cs.sizeOf() > 0, "no-arg ZstdCompressStream.sizeOf() not positive"); + check(cs.sizeOf().value() > 0, "no-arg ZstdCompressStream.sizeOf() not positive"); } byte[] original = sampleText(); try (Arena arena = Arena.ofConfined(); ZstdCompressStream cs = new ZstdCompressStream(Zstd.defaultCompressionLevel())) { MemorySegment src = toNative(arena, original); - MemorySegment dst = arena.allocate(Zstd.compressBound(src.byteSize())); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(src.byteSize())).value()); ZstdStreamResult result = cs.compress(dst, src, ZstdEndDirective.END); check(result.isComplete(), "single-shot ZstdCompressStream.compress(END) did not complete"); check(result.bytesConsumed() == original.length, "ZstdCompressStream consumed byte count mismatch"); - check(cs.sizeOf() > 0, "ZstdCompressStream.sizeOf() not positive"); + check(cs.sizeOf().value() > 0, "ZstdCompressStream.sizeOf() not positive"); // Reads a native struct (ZSTD_frameProgression) through fixed field // offsets — worth checking per platform since padding/alignment is @@ -346,7 +347,7 @@ void streamingZeroCopy() { check(decodeResult.isComplete(), "ZstdDecompressStream.decompress() did not complete"); checkArrayEquals(original, toByteArray(out, decodeResult.bytesProduced()), "zero-copy streaming round-trip mismatch"); - check(ds.sizeOf() > 0, "ZstdDecompressStream.sizeOf() not positive"); + check(ds.sizeOf().value() > 0, "ZstdDecompressStream.sizeOf() not positive"); } } } @@ -365,11 +366,11 @@ void streamingIo() throws IOException { ByteArrayOutputStream sinkPledged = new ByteArrayOutputStream(); try (ZstdOutputStream zout = - ZstdOutputStream.withPledgedSize(sinkPledged, new ZstdCompressionLevel(5), original.length)) { + ZstdOutputStream.withPledgedSize(sinkPledged, new ZstdCompressionLevel(5), new ZstdByteSize(original.length))) { zout.write(original); } byte[] pledgedFrame = sinkPledged.toByteArray(); - check(ZstdFrame.header(pledgedFrame).contentSize().orElseThrow() == original.length, + check(ZstdFrame.header(pledgedFrame).contentSize().orElseThrow().value() == original.length, "withPledgedSize() did not stamp the declared content size into the frame header"); checkArrayEquals(original, Zstd.decompress(pledgedFrame), "withPledgedSize() round-trip mismatch"); } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3860ca1..87e5b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ git tags, which trigger publication to Maven Central. ## [Unreleased] ### Changed +- **Breaking:** every public API that took or returned a naked `int`/`long` byte + size or count now takes/returns a `ZstdByteSize` value type, which rejects + negative values at construction (throwing `IllegalArgumentException`) — once + you hold a `ZstdByteSize` it is guaranteed valid, no more wondering whether a + raw `long` from the library still needs checking. Sizes that must fit a + `byte[]` narrow via `ZstdByteSize.toIntExact()` (throwing + `ArithmeticException` above `Integer.MAX_VALUE`), matching the JDK's own + `Math.toIntExact`; native and streaming totals pass through as `long`. A + frame-declared content size that is a zstd sentinel, or otherwise invalid + (including the unsigned range above `Long.MAX_VALUE`, read as a negative + `long`), fails fast with `ZstdException` via + `ZstdByteSize.fromFrameContentSize(long)` rather than reaching the + constructor's generic negative-value guard; `ZstdFrameHeader.contentSize()` + is now `Optional` (was `OptionalLong`) via the sibling + `ZstdByteSize.fromFrameHeaderContentSize(long)`. Affects + `Zstd.decompress(byte[], …)`, `Zstd.compressBound`, + `Zstd.estimateCompressContextSize`/`estimateDecompressContextSize`/ + `estimateCompressDictSize`/`estimateDecompressDictSize`, + `Zstd.decompressedSize(MemorySegment)`, + `ZstdDictionary.train`/`trainCover`/`trainFastCover`/`finalizeFrom`, + `ZstdOutputStream.withPledgedSize`, `ZstdFrame.decompressedSize`/ + `decompressedBound`, `ZstdFrameHeader.contentSize`, and `sizeOf()` on + `ZstdCompressContext`/`ZstdDecompressContext`/`ZstdCompressStream`/ + `ZstdDecompressStream`/`ZstdCompressDictionary`/`ZstdDecompressDictionary`. + Use `new ZstdByteSize(n)`, or `ZstdByteSize.ofKiB(n)`/`ofMiB(n)` for a size + expressed in KiB/MiB. + ([#96](https://github.com/dfa1/zstd-java/issues/96)) - **Breaking:** every public API that took a raw `int` compression level now takes a `ZstdCompressionLevel` value type, which validates the level against the linked libzstd's accepted range at construction (throwing diff --git a/README.md b/README.md index 132ed5e..af3613f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ import io.github.dfa1.zstd.*; import java.util.List; List samples = ...; // representative records -ZstdDictionary dict = ZstdDictionary.train(samples, 8 * 1024); +ZstdDictionary dict = ZstdDictionary.train(samples, ZstdByteSize.ofKiB(8)); byte[] message = ...; try (ZstdCompressContext cctx = new ZstdCompressContext(); diff --git a/benchmark/src/main/java/io/github/dfa1/zstd/bench/CompressBenchmark.java b/benchmark/src/main/java/io/github/dfa1/zstd/bench/CompressBenchmark.java index 6582dfe..7e50852 100644 --- a/benchmark/src/main/java/io/github/dfa1/zstd/bench/CompressBenchmark.java +++ b/benchmark/src/main/java/io/github/dfa1/zstd/bench/CompressBenchmark.java @@ -4,6 +4,7 @@ import io.airlift.compress.v3.zstd.ZstdJavaCompressor; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressionLevel; import java.lang.foreign.Arena; @@ -36,6 +37,8 @@ @Measurement(iterations = 5) public class CompressBenchmark { + // 1 KiB, 64 KiB, 1 MiB, 64 MiB — JMH @Param requires compile-time constant + // Strings, so these can't be expressed via ZstdByteSize.ofKiB/ofMiB. @Param({"1024", "65536", "1048576", "67108864"}) private int size; @@ -57,7 +60,7 @@ public class CompressBenchmark { @Setup(Level.Trial) public void setup() { src = BenchData.generate(size); - int bound = (int) Zstd.compressBound(size); + int bound = Zstd.compressBound(new ZstdByteSize(size)).toIntExact(); ffmCtx = new ZstdCompressContext().level(new ZstdCompressionLevel(level)); ffmDst = new byte[bound]; diff --git a/benchmark/src/main/java/io/github/dfa1/zstd/bench/DecompressBenchmark.java b/benchmark/src/main/java/io/github/dfa1/zstd/bench/DecompressBenchmark.java index 5a53697..291be7f 100644 --- a/benchmark/src/main/java/io/github/dfa1/zstd/bench/DecompressBenchmark.java +++ b/benchmark/src/main/java/io/github/dfa1/zstd/bench/DecompressBenchmark.java @@ -35,6 +35,8 @@ @Measurement(iterations = 5) public class DecompressBenchmark { + // 1 KiB, 64 KiB, 1 MiB, 64 MiB — JMH @Param requires compile-time constant + // Strings, so these can't be expressed via ZstdByteSize.ofKiB/ofMiB. @Param({"1024", "65536", "1048576", "67108864"}) private int size; diff --git a/benchmark/src/main/java/io/github/dfa1/zstd/bench/GoldenCorpusBenchmark.java b/benchmark/src/main/java/io/github/dfa1/zstd/bench/GoldenCorpusBenchmark.java index a0e4687..e84f562 100644 --- a/benchmark/src/main/java/io/github/dfa1/zstd/bench/GoldenCorpusBenchmark.java +++ b/benchmark/src/main/java/io/github/dfa1/zstd/bench/GoldenCorpusBenchmark.java @@ -3,6 +3,7 @@ import static java.lang.foreign.ValueLayout.JAVA_BYTE; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressionLevel; import io.github.dfa1.zstd.ZstdDecompressContext; @@ -91,7 +92,7 @@ public void setup() { cctx = new ZstdCompressContext().level(new ZstdCompressionLevel(level)); dctx = new ZstdDecompressContext(); - bound = (int) Zstd.compressBound(srcSize); + bound = Zstd.compressBound(new ZstdByteSize(srcSize)).toIntExact(); compressDst = new byte[bound]; arena = Arena.ofConfined(); diff --git a/benchmark/src/main/java/io/github/dfa1/zstd/bench/MultiThreadCompressBenchmark.java b/benchmark/src/main/java/io/github/dfa1/zstd/bench/MultiThreadCompressBenchmark.java index 7aec9f6..c0dff6f 100644 --- a/benchmark/src/main/java/io/github/dfa1/zstd/bench/MultiThreadCompressBenchmark.java +++ b/benchmark/src/main/java/io/github/dfa1/zstd/bench/MultiThreadCompressBenchmark.java @@ -3,6 +3,7 @@ import static java.lang.foreign.ValueLayout.JAVA_BYTE; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressionLevel; import io.github.dfa1.zstd.ZstdCompressParameter; @@ -40,6 +41,8 @@ @Measurement(iterations = 5) public class MultiThreadCompressBenchmark { + // 1 MiB, 64 MiB — JMH @Param requires compile-time constant Strings, so + // these can't be expressed via ZstdByteSize.ofMiB. @Param({"1048576", "67108864"}) private int size; @@ -58,7 +61,7 @@ public class MultiThreadCompressBenchmark { @Setup(Level.Trial) public void setup() { byte[] src = BenchData.generate(size); - int bound = (int) Zstd.compressBound(size); + int bound = Zstd.compressBound(new ZstdByteSize(size)).toIntExact(); ctx = new ZstdCompressContext().level(new ZstdCompressionLevel(level)); if (nbWorkers > 0) { diff --git a/docs/how-to.md b/docs/how-to.md index 33db9e0..ef9ba83 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -90,7 +90,7 @@ dictionary compresses each one far smaller than it could be alone. Train one on representative samples: ```java -ZstdDictionary dict = ZstdDictionary.train(sampleRecords, 16 * 1024); +ZstdDictionary dict = ZstdDictionary.train(sampleRecords, ZstdByteSize.ofKiB(16)); try (ZstdCompressContext cctx = new ZstdCompressContext(); ZstdDecompressContext dctx = new ZstdDecompressContext()) { @@ -124,8 +124,8 @@ hands zstd the segment address directly: no copy in, no copy out, no GC churn. try (Arena arena = Arena.ofConfined(); ZstdDecompressContext dctx = new ZstdDecompressContext()) { MemorySegment frame = reader.mmapSlice(); // already native - long n = Zstd.decompressedSize(frame); // read header, no copy - MemorySegment out = arena.allocate(n); // becomes the backing buffer + ZstdByteSize n = Zstd.decompressedSize(frame); // read header, no copy + MemorySegment out = arena.allocate(n.value()); // becomes the backing buffer dctx.decompress(out, frame); // native → native } ``` @@ -144,7 +144,7 @@ The segment-API map: | decompress + dict | `ZstdDecompressContext.decompress(byte[], int, ZstdDecompressDictionary)` | `ZstdDecompressContext.decompress(dst, src, ZstdDecompressDictionary)` | | size output (no copy) | frame header via `Zstd.decompress(byte[])` | `Zstd.decompressedSize(MemorySegment)` | -Size `dst` with `Zstd.compressBound(srcSize)` for compression, or +Size `dst` with `Zstd.compressBound(new ZstdByteSize(srcSize))` for compression, or `Zstd.decompressedSize(frame)` for decompression. ## Let the codec size and allocate the output @@ -231,7 +231,7 @@ can't size the arena (see [the explanation](zero-copy.md)). Tell the encoder the total up front and it stamps the content size into the header: ```java -try (var zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), data.length)) { +try (var zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), new ZstdByteSize(data.length))) { zout.write(data); // pledge must equal bytes written } MemorySegment src = MemorySegment.ofBuffer(mmap); // downstream, in a mapped reader diff --git a/docs/tutorial.md b/docs/tutorial.md index 82639d6..bfe182f 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -97,8 +97,8 @@ the `MemorySegment` overloads hand zstd the segment address directly, so there i try (Arena arena = Arena.ofConfined(); ZstdDecompressContext dctx = new ZstdDecompressContext()) { MemorySegment frame = reader.mmapSlice(); // already native — never touches the heap - long n = Zstd.decompressedSize(frame); // read the header, no copy - MemorySegment out = arena.allocate(n); // this segment *is* the output buffer + ZstdByteSize n = Zstd.decompressedSize(frame); // read the header, no copy + MemorySegment out = arena.allocate(n.value()); // this segment *is* the output buffer dctx.decompress(out, frame); // native → native } ``` diff --git a/integration-tests/src/test/java/io/github/dfa1/zstd/it/GoldenCorpusTest.java b/integration-tests/src/test/java/io/github/dfa1/zstd/it/GoldenCorpusTest.java index 33be85c..a2a3a99 100644 --- a/integration-tests/src/test/java/io/github/dfa1/zstd/it/GoldenCorpusTest.java +++ b/integration-tests/src/test/java/io/github/dfa1/zstd/it/GoldenCorpusTest.java @@ -2,6 +2,7 @@ import io.github.dfa1.zstd.ZstdDictionaryId; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressionLevel; import io.github.dfa1.zstd.ZstdDecompressContext; @@ -153,7 +154,7 @@ void jniCompressJavaDecompress(String name, Path file) { byte[] frame = com.github.luben.zstd.Zstd.compress(data, Zstd.maxCompressionLevel()); // Then - assertThat(Zstd.decompress(frame, data.length)).isEqualTo(data); + assertThat(Zstd.decompress(frame, new ZstdByteSize(data.length))).isEqualTo(data); } } 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 434eae1..0557539 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 @@ -3,6 +3,7 @@ import com.github.luben.zstd.ZstdCompressCtx; import io.github.dfa1.zstd.ZstdDictionaryId; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressionLevel; import io.github.dfa1.zstd.ZstdDictionary; import io.github.dfa1.zstd.ZstdException; @@ -210,7 +211,7 @@ void javaReadsContentSizeFromJniFrame() { // Then assertThat(header.frameType()).isEqualTo(ZstdFrameType.STANDARD); - assertThat(header.contentSize()).hasValue(data.length); + assertThat(header.contentSize()).hasValue(new ZstdByteSize(data.length)); } @Test @@ -235,7 +236,7 @@ private ZstdDictionary trainDict() { for (int i = 0; i < 3000; i++) { samples.add(sample(i)); } - return ZstdDictionary.train(samples, 8 * 1024); + return ZstdDictionary.train(samples, new ZstdByteSize(8 * 1024)); } private byte[] sample(int i) { diff --git a/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java b/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java index 04173a0..d3e1825 100644 --- a/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java +++ b/integration-tests/src/test/java/io/github/dfa1/zstd/it/ZstdJniInteropTest.java @@ -3,6 +3,7 @@ import com.github.luben.zstd.ZstdDictCompress; import com.github.luben.zstd.ZstdDictDecompress; import io.github.dfa1.zstd.Zstd; +import io.github.dfa1.zstd.ZstdByteSize; import io.github.dfa1.zstd.ZstdCompressContext; import io.github.dfa1.zstd.ZstdCompressDictionary; import io.github.dfa1.zstd.ZstdCompressionLevel; @@ -54,7 +55,7 @@ static Stream payloadsAndLevels() { @MethodSource("payloadsAndLevels") void jniCompressJavaDecompress(int level, byte[] data) { byte[] frame = com.github.luben.zstd.Zstd.compress(data, level); - assertThat(Zstd.decompress(frame, data.length)).isEqualTo(data); + assertThat(Zstd.decompress(frame, new ZstdByteSize(data.length))).isEqualTo(data); } @ParameterizedTest @@ -127,7 +128,7 @@ void jniMultiThreadCompressJavaDecompress() throws Exception { zout.setWorkers(2); zout.write(data); } - assertThat(Zstd.decompress(sink.toByteArray(), data.length)).isEqualTo(data); + assertThat(Zstd.decompress(sink.toByteArray(), new ZstdByteSize(data.length))).isEqualTo(data); } @Test @@ -212,7 +213,7 @@ private ZstdDictionary trainDict() { for (int i = 0; i < 3000; i++) { samples.add(sample(i)); } - return ZstdDictionary.train(samples, 8 * 1024); + return ZstdDictionary.train(samples, new ZstdByteSize(8 * 1024)); } private byte[] sample(int i) { 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 b18779b..d5b1eb8 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/Zstd.java @@ -19,11 +19,6 @@ /// } public final class Zstd { - /// Sentinel returned by zstd when a frame carries no decompressed-size header. - static final long CONTENTSIZE_UNKNOWN = -1L; - /// Sentinel returned by zstd when the input is not a valid zstd frame. - static final long CONTENTSIZE_ERROR = -2L; - /// Compresses `src` at the library default level. /// /// @param src bytes to compress @@ -41,10 +36,10 @@ public static byte[] compress(byte[] src, ZstdCompressionLevel level) { Objects.requireNonNull(src, "src"); try (Arena arena = Arena.ofConfined()) { MemorySegment in = copyIn(arena, src); - long bound = compressBound(src.length); - MemorySegment out = arena.allocate(bound); + ZstdByteSize bound = compressBound(new ZstdByteSize(src.length)); + MemorySegment out = arena.allocate(bound.value()); long written = NativeCall.checkReturnValue(() -> (long) Bindings.COMPRESS.invokeExact( - out, bound, in, (long) src.length, level.value())); + out, bound.value(), in, (long) src.length, level.value())); return copyOut(out, written); } } @@ -56,34 +51,35 @@ public static byte[] compress(byte[] src, ZstdCompressionLevel level) { /// header and allocates a buffer of that size. The header is part of the input, /// so a hostile frame can declare a large size (up to the maximum array length) /// and force a correspondingly large allocation — a decompression-bomb denial of - /// service. For input you do not control, use [#decompress(byte[], int)] with a + /// service. For input you do not control, use [#decompress(byte[], ZstdByteSize)] with a /// sane bound instead. /// /// @param compressed a complete zstd frame /// @return the original bytes /// @throws ZstdException if the frame is invalid, its content size is not stored - /// (use [#decompress(byte[], int)] for the latter), or the + /// (use [#decompress(byte[], ZstdByteSize)] for the latter), or the /// declared size exceeds the maximum array length public static byte[] decompress(byte[] compressed) { Objects.requireNonNull(compressed, "compressed"); - long size = requireStoredContentSize(frameContentSize(compressed)); - return decompress(compressed, toArrayLength(size)); + ZstdByteSize size = frameContentSize(compressed); + return decompress(compressed, new ZstdByteSize(toArrayLength(size))); } /// Narrows a frame-declared content size to a `byte[]` length, rejecting sizes /// that exceed what a Java array can hold. The size comes from the (untrusted) /// frame header, so this fails with a [ZstdException] rather than letting a raw - /// `ArithmeticException` escape. + /// `ArithmeticException` escape [ZstdByteSize#toIntExact()]. /// - /// @param size a non-negative content size from a frame header + /// @param size a content size from a frame header /// @return `size` as an `int` /// @throws ZstdException if `size` exceeds [Integer#MAX_VALUE] - private static int toArrayLength(long size) { - if (size > Integer.MAX_VALUE) { - throw new ZstdException("decompressed size " + size - + " exceeds the maximum array length; use decompress(byte[], int) to bound it"); + private static int toArrayLength(ZstdByteSize size) { + try { + return size.toIntExact(); + } catch (ArithmeticException e) { + throw new ZstdException("decompressed size " + size.value() + + " exceeds the maximum array length; use decompress(byte[], ZstdByteSize) to bound it"); } - return (int) size; } /// Decompresses a zstd frame into a buffer of at most `maxSize` bytes. @@ -95,11 +91,12 @@ private static int toArrayLength(long size) { /// afford; the frame is rejected if its content exceeds it. /// /// @param compressed a complete zstd frame - /// @param maxSize upper bound on the decompressed length - /// @return the original bytes (length ≤ `maxSize`) - /// @throws ZstdException if the frame is invalid or larger than `maxSize` - public static byte[] decompress(byte[] compressed, int maxSize) { + /// @param maxSizeBound upper bound on the decompressed length + /// @return the original bytes (length ≤ `maxSizeBound`) + /// @throws ZstdException if the frame is invalid or larger than `maxSizeBound` + public static byte[] decompress(byte[] compressed, ZstdByteSize maxSizeBound) { Objects.requireNonNull(compressed, "compressed"); + int maxSize = maxSizeBound.toIntExact(); try (Arena arena = Arena.ofConfined()) { MemorySegment in = copyIn(arena, compressed); MemorySegment out = arena.allocate(Math.max(maxSize, 1)); @@ -116,7 +113,7 @@ public static byte[] decompress(byte[] compressed, int maxSize) { /// @param frame a complete zstd frame /// @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) { + public static ZstdByteSize decompressedSize(MemorySegment frame) { NativeCall.requireNative(frame, "frame"); long size; try { @@ -124,23 +121,7 @@ public static long decompressedSize(MemorySegment frame) { } catch (Throwable t) { throw NativeCall.rethrow(t); } - return requireStoredContentSize(size); - } - - /// Maps a raw `ZSTD_getFrameContentSize` / `ZSTD_findDecompressedSize` result to - /// a usable length, turning zstd's negative sentinels into exceptions. - /// - /// @param size a content size, or a `CONTENTSIZE_UNKNOWN` / `CONTENTSIZE_ERROR` sentinel - /// @return `size` when it is a real length - /// @throws ZstdException if the size is not stored, or the input is not valid zstd data - static long requireStoredContentSize(long size) { - if (size == CONTENTSIZE_UNKNOWN) { - throw new ZstdException("decompressed size not stored in frame"); - } - if (size == CONTENTSIZE_ERROR) { - throw new ZstdException("not a valid zstd frame"); - } - return size; + return ZstdByteSize.fromFrameContentSize(size); } /// Dictionary id stamped in raw dictionary `bytes`, read with the core @@ -184,22 +165,25 @@ private static ZstdDictionaryId dictId(MemorySegment bytes, long size) { /// /// @param srcSize the uncompressed input length in bytes /// @return the worst-case compressed size for that input - public static long compressBound(long srcSize) { + public static ZstdByteSize compressBound(ZstdByteSize srcSize) { try { - return (long) Bindings.COMPRESS_BOUND.invokeExact(srcSize); + return new ZstdByteSize((long) Bindings.COMPRESS_BOUND.invokeExact(srcSize.value())); } catch (Throwable t) { throw NativeCall.rethrow(t); } } - /// Decompressed size stored in the frame header, or a negative sentinel. - private static long frameContentSize(byte[] compressed) { + /// Decompressed size stored in the frame header, validated via + /// [ZstdByteSize#fromFrameContentSize(long)]. + private static ZstdByteSize frameContentSize(byte[] compressed) { + long raw; try (Arena arena = Arena.ofConfined()) { MemorySegment in = copyIn(arena, compressed); - return (long) Bindings.GET_FRAME_CONTENT_SIZE.invokeExact(in, (long) compressed.length); + raw = (long) Bindings.GET_FRAME_CONTENT_SIZE.invokeExact(in, (long) compressed.length); } catch (Throwable t) { throw NativeCall.rethrow(t); } + return ZstdByteSize.fromFrameContentSize(raw); } /// Highest supported compression level. @@ -240,9 +224,9 @@ public static int defaultCompressionLevel() { /// /// @param level the compression level to use /// @return the estimated context size in bytes - public static long estimateCompressContextSize(ZstdCompressionLevel level) { + public static ZstdByteSize estimateCompressContextSize(ZstdCompressionLevel level) { try { - return (long) Bindings.ESTIMATE_CCTX_SIZE.invokeExact(level.value()); + return new ZstdByteSize((long) Bindings.ESTIMATE_CCTX_SIZE.invokeExact(level.value())); } catch (Throwable t) { throw NativeCall.rethrow(t); } @@ -251,9 +235,9 @@ public static long estimateCompressContextSize(ZstdCompressionLevel level) { /// Estimates the memory a decompression context will use. /// /// @return the estimated context size in bytes - public static long estimateDecompressContextSize() { + public static ZstdByteSize estimateDecompressContextSize() { try { - return (long) Bindings.ESTIMATE_DCTX_SIZE.invokeExact(); + return new ZstdByteSize((long) Bindings.ESTIMATE_DCTX_SIZE.invokeExact()); } catch (Throwable t) { throw NativeCall.rethrow(t); } @@ -265,9 +249,9 @@ public static long estimateDecompressContextSize() { /// @param dictSize the raw dictionary size in bytes /// @param level the compression level to use /// @return the estimated digested-dictionary size in bytes - public static long estimateCompressDictSize(long dictSize, ZstdCompressionLevel level) { + public static ZstdByteSize estimateCompressDictSize(ZstdByteSize dictSize, ZstdCompressionLevel level) { try { - return (long) Bindings.ESTIMATE_CDICT_SIZE.invokeExact(dictSize, level.value()); + return new ZstdByteSize((long) Bindings.ESTIMATE_CDICT_SIZE.invokeExact(dictSize.value(), level.value())); } catch (Throwable t) { throw NativeCall.rethrow(t); } @@ -278,9 +262,10 @@ public static long estimateCompressDictSize(long dictSize, ZstdCompressionLevel /// /// @param dictSize the raw dictionary size in bytes /// @return the estimated digested-dictionary size in bytes - public static long estimateDecompressDictSize(long dictSize) { + public static ZstdByteSize estimateDecompressDictSize(ZstdByteSize dictSize) { try { - return (long) Bindings.ESTIMATE_DDICT_SIZE.invokeExact(dictSize, 0); // ZSTD_dlm_byCopy + // ZSTD_dlm_byCopy + return new ZstdByteSize((long) Bindings.ESTIMATE_DDICT_SIZE.invokeExact(dictSize.value(), 0)); } catch (Throwable t) { throw NativeCall.rethrow(t); } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdByteSize.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdByteSize.java new file mode 100644 index 0000000..fde7399 --- /dev/null +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdByteSize.java @@ -0,0 +1,94 @@ +package io.github.dfa1.zstd; + +import java.util.Optional; + +/// A validated, non-negative byte size or count, replacing a naked `int`/`long` +/// at every public API boundary that takes one. +/// +/// Backed by `long` uniformly: some sizes here are native-segment or streaming +/// totals that can exceed [Integer#MAX_VALUE] (e.g. +/// [ZstdOutputStream#withPledgedSize(java.io.OutputStream, ZstdCompressionLevel, ZstdByteSize)]), +/// while others end up sizing a `byte[]` and so must additionally fit an `int` +/// (e.g. [Zstd#decompress(byte[], ZstdByteSize)]). The latter narrow with +/// [#toIntExact()] rather than silently truncating. +/// +/// @param value the byte size or count, non-negative +public record ZstdByteSize(long value) { + + // zstd's own sentinels for "not stored" / "error" on a frame-content-size + // read (ZSTD_CONTENTSIZE_UNKNOWN / ZSTD_CONTENTSIZE_ERROR) — an implementation + // detail of interpreting those native results, not part of the public type. + private static final long CONTENTSIZE_UNKNOWN = -1L; + private static final long CONTENTSIZE_ERROR = -2L; + + /// Validates `value` is non-negative. + public ZstdByteSize { + if (value < 0) { + throw new IllegalArgumentException("size " + value + " must not be negative"); + } + } + + /// Narrows this size to an `int`, for callers that must size a `byte[]`. + /// + /// @return this size as an `int` + /// @throws ArithmeticException if the size exceeds [Integer#MAX_VALUE] + public int toIntExact() { + return Math.toIntExact(value); + } + + /// `value` KiB (1024 bytes each) — e.g. `ZstdByteSize.ofKiB(8)` for an 8 KiB + /// buffer, clearer than spelling out `new ZstdByteSize(8 * 1024)`. + /// + /// @param value the count of KiB + /// @return a size of `value * 1024` bytes + /// @throws IllegalArgumentException if `value` is negative + /// @throws ArithmeticException if `value * 1024` overflows a `long` + public static ZstdByteSize ofKiB(long value) { + return new ZstdByteSize(Math.multiplyExact(value, 1024L)); + } + + /// `value` MiB (1024 * 1024 bytes each). + /// + /// @param value the count of MiB + /// @return a size of `value * 1024 * 1024` bytes + /// @throws IllegalArgumentException if `value` is negative + /// @throws ArithmeticException if `value * 1024 * 1024` overflows a `long` + public static ZstdByteSize ofMiB(long value) { + return new ZstdByteSize(Math.multiplyExact(value, 1024L * 1024L)); + } + + /// Wraps a `ZSTD_getFrameContentSize` / `ZSTD_findDecompressedSize` / + /// `ZSTD_decompressBound` result, turning zstd's negative sentinels — and any + /// other negative reading, which can only mean the field's true value (zstd + /// reads it as `unsigned long long`) landed at or above `2^63` and wrapped + /// around a signed `long` — into a [ZstdException] instead of a bogus + /// negative size or the wrong exception type. + /// + /// @param raw a content size, or a zstd "not stored" / "error" sentinel + /// @return `raw` wrapped as a [ZstdByteSize] when it is a real length + /// @throws ZstdException if the size is not stored, or the input is not valid zstd data + static ZstdByteSize fromFrameContentSize(long raw) { + if (raw == CONTENTSIZE_UNKNOWN) { + throw new ZstdException("decompressed size not stored in frame"); + } + if (raw == CONTENTSIZE_ERROR) { + throw new ZstdException("not a valid zstd frame"); + } + if (raw < 0) { + throw new ZstdException("decompressed size " + raw + " is not a valid zstd frame content size"); + } + return new ZstdByteSize(raw); + } + + /// Wraps a `ZSTD_getFrameHeader`-parsed content size, returning empty instead + /// of throwing when the frame does not record one — unlike + /// [#fromFrameContentSize(long)], a [ZstdFrameHeader] was already validated + /// by the time this runs, so the only remaining case is "not stored", not + /// "invalid". + /// + /// @param raw a content size, or the "not stored" sentinel + /// @return the wrapped size, or empty if the frame does not store one + static Optional fromFrameHeaderContentSize(long raw) { + return raw == CONTENTSIZE_UNKNOWN ? Optional.empty() : Optional.of(new ZstdByteSize(raw)); + } +} 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 684927a..637bc5c 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressContext.java @@ -224,10 +224,10 @@ public byte[] compress(byte[] src) { Objects.requireNonNull(src, "src"); try (Arena arena = Arena.ofConfined()) { MemorySegment in = Zstd.copyIn(arena, src); - long bound = Zstd.compressBound(src.length); - MemorySegment out = arena.allocate(bound); + ZstdByteSize bound = Zstd.compressBound(new ZstdByteSize(src.length)); + MemorySegment out = arena.allocate(bound.value()); long written = NativeCall.checkReturnValue(() -> (long) Bindings.COMPRESS2.invokeExact( - ptr(), out, bound, in, (long) src.length)); + ptr(), out, bound.value(), in, (long) src.length)); return Zstd.copyOut(out, written); } } @@ -252,10 +252,10 @@ public byte[] compress(byte[] src, ZstdDictionary dict) { MemorySegment in = Zstd.copyIn(arena, src); byte[] d = dict.raw(); MemorySegment dseg = Zstd.copyIn(arena, d); - long bound = Zstd.compressBound(src.length); - MemorySegment out = arena.allocate(bound); + ZstdByteSize bound = Zstd.compressBound(new ZstdByteSize(src.length)); + MemorySegment out = arena.allocate(bound.value()); long written = NativeCall.checkReturnValue(() -> (long) Bindings.COMPRESS_USING_DICT.invokeExact( - ptr(), out, bound, in, (long) src.length, dseg, (long) d.length, level.value())); + ptr(), out, bound.value(), in, (long) src.length, dseg, (long) d.length, level.value())); return Zstd.copyOut(out, written); } } @@ -271,11 +271,11 @@ public byte[] compress(byte[] src, ZstdCompressDictionary dict) { Objects.requireNonNull(dict, "dict"); try (Arena arena = Arena.ofConfined()) { MemorySegment in = Zstd.copyIn(arena, src); - long bound = Zstd.compressBound(src.length); - MemorySegment out = arena.allocate(bound); + ZstdByteSize bound = Zstd.compressBound(new ZstdByteSize(src.length)); + MemorySegment out = arena.allocate(bound.value()); MemorySegment cdict = dict.ptr(); long written = NativeCall.checkReturnValue(() -> (long) Bindings.COMPRESS_USING_CDICT.invokeExact( - ptr(), out, bound, in, (long) src.length, cdict)); + ptr(), out, bound.value(), in, (long) src.length, cdict)); return Zstd.copyOut(out, written); } } @@ -286,7 +286,7 @@ public byte[] compress(byte[] src, ZstdCompressDictionary dict) { /// the fast path when your bytes are already off-heap (e.g. an mmap slice and /// an arena-allocated output); see `docs/zero-copy.md`. /// - /// Size `dst` with [Zstd#compressBound(long)] to guarantee it fits. + /// Size `dst` with [Zstd#compressBound(ZstdByteSize)] to guarantee it fits. /// /// @param dst the native destination buffer to write the frame into /// @param src the native source bytes to compress @@ -314,7 +314,7 @@ public long compress(MemorySegment dst, MemorySegment src, ZstdCompressDictionar } /// Zero-copy compression that allocates the output for you: reserves a - /// worst-case buffer ([Zstd#compressBound(long)]) in `arena`, + /// worst-case buffer ([Zstd#compressBound(ZstdByteSize)]) in `arena`, /// compresses into it, and returns a slice trimmed to the actual frame length. /// The returned segment is owned by `arena`. /// @@ -322,7 +322,7 @@ public long compress(MemorySegment dst, MemorySegment src, ZstdCompressDictionar /// @param src the native source bytes to compress /// @return the zstd frame, a slice of an `arena`-owned segment public MemorySegment compress(Arena arena, MemorySegment src) { - MemorySegment dst = arena.allocate(Zstd.compressBound(src.byteSize())); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(src.byteSize())).value()); long written = compress(dst, src); return dst.asSlice(0, written); } @@ -335,7 +335,7 @@ public MemorySegment compress(Arena arena, MemorySegment src) { /// @param dict the pre-digested compression dictionary /// @return the zstd frame, a slice of an `arena`-owned segment public MemorySegment compress(Arena arena, MemorySegment src, ZstdCompressDictionary dict) { - MemorySegment dst = arena.allocate(Zstd.compressBound(src.byteSize())); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(src.byteSize())).value()); long written = compress(dst, src, dict); return dst.asSlice(0, written); } @@ -343,9 +343,9 @@ public MemorySegment compress(Arena arena, MemorySegment src, ZstdCompressDictio /// Current native memory used by this context, in bytes. /// /// @return the live context size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_CCTX.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_CCTX.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressDictionary.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressDictionary.java index 2d53d30..760473e 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressDictionary.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressDictionary.java @@ -93,9 +93,9 @@ public ZstdDictionaryId id() { /// Current native memory used by this digested dictionary, in bytes. /// /// @return the live dictionary size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_CDICT.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_CDICT.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } 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 a776159..230cd32 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdCompressStream.java @@ -141,9 +141,9 @@ public ZstdFrameProgression progress() { /// Current native memory used by this stream's context, in bytes. /// /// @return the live context size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_CCTX.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_CCTX.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } 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 bece153..cccef60 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressContext.java @@ -264,8 +264,8 @@ public long decompress(MemorySegment dst, MemorySegment src, ZstdDecompressDicti /// @return a segment of exactly the decompressed length, allocated in `arena` /// @throws ZstdException if the frame is invalid or stores no size public MemorySegment decompress(Arena arena, MemorySegment frame) { - long size = Zstd.decompressedSize(frame); - MemorySegment out = arena.allocate(size); + ZstdByteSize size = Zstd.decompressedSize(frame); + MemorySegment out = arena.allocate(size.value()); decompress(out, frame); return out; } @@ -278,8 +278,8 @@ public MemorySegment decompress(Arena arena, MemorySegment frame) { /// @param dict the pre-digested decompression dictionary /// @return a segment of exactly the decompressed length, allocated in `arena` public MemorySegment decompress(Arena arena, MemorySegment frame, ZstdDecompressDictionary dict) { - long size = Zstd.decompressedSize(frame); - MemorySegment out = arena.allocate(size); + ZstdByteSize size = Zstd.decompressedSize(frame); + MemorySegment out = arena.allocate(size.value()); decompress(out, frame, dict); return out; } @@ -287,9 +287,9 @@ public MemorySegment decompress(Arena arena, MemorySegment frame, ZstdDecompress /// Current native memory used by this context, in bytes. /// /// @return the live context size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_DCTX.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_DCTX.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressDictionary.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressDictionary.java index af923a7..73d2f5e 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressDictionary.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressDictionary.java @@ -63,9 +63,9 @@ public ZstdDictionaryId id() { /// Current native memory used by this digested dictionary, in bytes. /// /// @return the live dictionary size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_DDICT.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_DDICT.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressStream.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressStream.java index ef3dfc7..643824e 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressStream.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDecompressStream.java @@ -78,9 +78,9 @@ public ZstdStreamResult decompress(MemorySegment dst, MemorySegment src) { /// Current native memory used by this stream's context, in bytes. /// /// @return the live context size - public long sizeOf() { + public ZstdByteSize sizeOf() { try { - return (long) Bindings.SIZEOF_DCTX.invokeExact(ptr()); + return new ZstdByteSize((long) Bindings.SIZEOF_DCTX.invokeExact(ptr())); } catch (Throwable t) { throw NativeCall.rethrow(t); } 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 30691c9..80d67d8 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdDictionary.java @@ -18,14 +18,14 @@ /// similar payloads (log lines, JSON records, protobufs) dramatically smaller /// than compressing each one independently. /// -/// Obtain one by [training][#train(List, int)] on representative +/// Obtain one by [training][#train(List, ZstdByteSize)] on representative /// samples, or wrap dictionary bytes you already have with [#of(byte[])]. /// Pass it to [ZstdCompressContext] / [ZstdDecompressContext] to compress and /// decompress against it. For a hot path, digest it once into a /// [ZstdCompressDictionary] / [ZstdDecompressDictionary]. /// /// {@snippet : -/// ZstdDictionary dict = ZstdDictionary.train(sampleRecords, 64 * 1024); +/// ZstdDictionary dict = ZstdDictionary.train(sampleRecords, ZstdByteSize.ofKiB(64)); /// try (ZstdCompressContext ctx = new ZstdCompressContext()) { /// byte[] packed = ctx.compress(record, dict); /// } @@ -90,13 +90,14 @@ public static ZstdDictionary of(byte[] raw) { /// Aim for at least a few hundred samples totalling ~100× the target /// dictionary size; too little data yields a weak dictionary. /// - /// @param samples representative payloads to learn from - /// @param maxDictBytes upper bound on the produced dictionary size (e.g. 110 KiB) + /// @param samples representative payloads to learn from + /// @param maxDictBytesBound upper bound on the produced dictionary size (e.g. 110 KiB) /// @return the trained dictionary /// @throws ZstdException if training fails (commonly: not enough sample data) - public static ZstdDictionary train(List samples, int maxDictBytes) { + public static ZstdDictionary train(List samples, ZstdByteSize maxDictBytesBound) { Objects.requireNonNull(samples, SAMPLES); requireNonEmpty(samples, "train"); + int maxDictBytes = maxDictBytesBound.toIntExact(); try (Arena arena = Arena.ofConfined()) { FlatSamples in = flatten(arena, samples); MemorySegment dictBuf = arena.allocate(maxDictBytes); @@ -119,7 +120,7 @@ public static ZstdDictionary train(List samples, int maxDictBytes) { /// @param maxDictBytes upper bound on the produced dictionary size /// @return the trained dictionary /// @throws ZstdException if training fails - public static ZstdDictionary trainCover(List samples, int maxDictBytes) { + public static ZstdDictionary trainCover(List samples, ZstdByteSize maxDictBytes) { return optimize(samples, maxDictBytes, 0, false); } @@ -131,7 +132,7 @@ public static ZstdDictionary trainCover(List samples, int maxDictBytes) /// whose [ZstdCompressionLevel#value()] is 0 = default) /// @return the trained dictionary /// @throws ZstdException if training fails - public static ZstdDictionary trainCover(List samples, int maxDictBytes, + public static ZstdDictionary trainCover(List samples, ZstdByteSize maxDictBytes, ZstdCompressionLevel compressionLevel) { return optimize(samples, maxDictBytes, compressionLevel.value(), false); } @@ -144,7 +145,7 @@ public static ZstdDictionary trainCover(List samples, int maxDictBytes, /// @param maxDictBytes upper bound on the produced dictionary size /// @return the trained dictionary /// @throws ZstdException if training fails - public static ZstdDictionary trainFastCover(List samples, int maxDictBytes) { + public static ZstdDictionary trainFastCover(List samples, ZstdByteSize maxDictBytes) { return optimize(samples, maxDictBytes, 0, true); } @@ -156,15 +157,16 @@ public static ZstdDictionary trainFastCover(List samples, int maxDictByt /// whose [ZstdCompressionLevel#value()] is 0 = default) /// @return the trained dictionary /// @throws ZstdException if training fails - public static ZstdDictionary trainFastCover(List samples, int maxDictBytes, + public static ZstdDictionary trainFastCover(List samples, ZstdByteSize maxDictBytes, ZstdCompressionLevel compressionLevel) { return optimize(samples, maxDictBytes, compressionLevel.value(), true); } - private static ZstdDictionary optimize(List samples, int maxDictBytes, + private static ZstdDictionary optimize(List samples, ZstdByteSize maxDictBytesBound, int compressionLevel, boolean fast) { Objects.requireNonNull(samples, SAMPLES); requireNonEmpty(samples, "train"); + int maxDictBytes = maxDictBytesBound.toIntExact(); try (Arena arena = Arena.ofConfined()) { FlatSamples in = flatten(arena, samples); // zeroed params (auto-tune k/d/steps); set single-threaded + target level. @@ -190,18 +192,19 @@ private static ZstdDictionary optimize(List samples, int maxDictBytes, /// header and entropy tables tuned on `samples`. Use this when you control the /// dictionary content and only want zstd to finalize it. /// - /// @param content the raw dictionary content to wrap - /// @param samples representative payloads to tune entropy tables on - /// @param maxDictBytes upper bound on the produced dictionary size - /// @param compressionLevel the level the dictionary will be used at (a level - /// whose [ZstdCompressionLevel#value()] is 0 = default) + /// @param content the raw dictionary content to wrap + /// @param samples representative payloads to tune entropy tables on + /// @param maxDictBytesBound upper bound on the produced dictionary size + /// @param compressionLevel the level the dictionary will be used at (a level + /// whose [ZstdCompressionLevel#value()] is 0 = default) /// @return the finalized dictionary /// @throws ZstdException if finalization fails public static ZstdDictionary finalizeFrom(byte[] content, List samples, - int maxDictBytes, ZstdCompressionLevel compressionLevel) { + ZstdByteSize maxDictBytesBound, ZstdCompressionLevel compressionLevel) { Objects.requireNonNull(content, "content"); Objects.requireNonNull(samples, SAMPLES); requireNonEmpty(samples, "finalize"); + int maxDictBytes = maxDictBytesBound.toIntExact(); try (Arena arena = Arena.ofConfined()) { FlatSamples in = flatten(arena, samples); MemorySegment contentSeg = Zstd.copyIn(arena, content); 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 0cd89bf..2c60184 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrame.java @@ -58,7 +58,7 @@ public static long compressedSize(MemorySegment data) { /// @param data one or more concatenated zstd frames /// @return an upper bound on the combined decompressed size /// @throws ZstdException if the input is not valid zstd data - public static long decompressedBound(byte[] data) { + public static ZstdByteSize decompressedBound(byte[] data) { try (Arena arena = Arena.ofConfined()) { return decompressedBound(Zstd.copyIn(arena, data), data.length); } @@ -69,7 +69,7 @@ public static long decompressedBound(byte[] data) { /// @param data one or more concatenated zstd frames /// @return an upper bound on the combined decompressed size /// @throws ZstdException if the input is not valid zstd data - public static long decompressedBound(MemorySegment data) { + public static ZstdByteSize decompressedBound(MemorySegment data) { return decompressedBound(data, data.byteSize()); } @@ -85,7 +85,7 @@ public static long decompressedBound(MemorySegment data) { /// @return the exact combined decompressed size /// @throws ZstdException if the input is not valid zstd data, or any frame does /// not record its decompressed size - public static long decompressedSize(byte[] data) { + public static ZstdByteSize decompressedSize(byte[] data) { try (Arena arena = Arena.ofConfined()) { return decompressedSize(Zstd.copyIn(arena, data), data.length); } @@ -98,7 +98,7 @@ public static long decompressedSize(byte[] data) { /// @return the exact combined decompressed size /// @throws ZstdException if the input is not valid zstd data, or any frame does /// not record its decompressed size - public static long decompressedSize(MemorySegment data) { + public static ZstdByteSize decompressedSize(MemorySegment data) { return decompressedSize(data, data.byteSize()); } @@ -288,7 +288,7 @@ private static long compressedSize(MemorySegment data, long size) { return NativeCall.checkReturnValue(() -> (long) Bindings.FIND_FRAME_COMPRESSED_SIZE.invokeExact(data, size)); } - private static long decompressedBound(MemorySegment data, long size) { + private static ZstdByteSize decompressedBound(MemorySegment data, long size) { long bound; try { bound = (long) Bindings.DECOMPRESS_BOUND.invokeExact(data, size); @@ -296,17 +296,17 @@ private static long decompressedBound(MemorySegment data, long size) { throw NativeCall.rethrow(t); } // ZSTD_decompressBound never returns CONTENTSIZE_UNKNOWN, only a bound or the error sentinel - return Zstd.requireStoredContentSize(bound); + return ZstdByteSize.fromFrameContentSize(bound); } - private static long decompressedSize(MemorySegment data, long size) { + private static ZstdByteSize decompressedSize(MemorySegment data, long size) { long total; try { total = (long) Bindings.FIND_DECOMPRESSED_SIZE.invokeExact(data, size); } catch (Throwable t) { throw NativeCall.rethrow(t); } - return Zstd.requireStoredContentSize(total); + return ZstdByteSize.fromFrameContentSize(total); } private static long decompressionMargin(MemorySegment data, long size) { diff --git a/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrameHeader.java b/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrameHeader.java index cf35a4f..6db678b 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrameHeader.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdFrameHeader.java @@ -1,6 +1,6 @@ package io.github.dfa1.zstd; -import java.util.OptionalLong; +import java.util.Optional; /// Parsed contents of a zstd frame header, from `ZSTD_getFrameHeader`. /// @@ -25,9 +25,7 @@ public record ZstdFrameHeader( /// The decompressed size, if the frame records it. /// /// @return the content size, or empty if the frame does not store it - public OptionalLong contentSize() { - return frameContentSize == Zstd.CONTENTSIZE_UNKNOWN - ? OptionalLong.empty() - : OptionalLong.of(frameContentSize); + public Optional contentSize() { + return ZstdByteSize.fromFrameHeaderContentSize(frameContentSize); } } 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 b916a23..87394f8 100644 --- a/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java +++ b/zstd/src/main/java/io/github/dfa1/zstd/ZstdOutputStream.java @@ -78,9 +78,9 @@ public ZstdOutputStream(OutputStream out, ZstdDictionary dictionary) { /// @param level the compression level to use /// @param pledgedSrcSize the exact number of uncompressed bytes that will be written /// @return a stream that will stamp the content size into the frame - public static ZstdOutputStream withPledgedSize(OutputStream out, ZstdCompressionLevel level, long pledgedSrcSize) { + public static ZstdOutputStream withPledgedSize(OutputStream out, ZstdCompressionLevel level, ZstdByteSize pledgedSrcSize) { ZstdOutputStream stream = new ZstdOutputStream(out, level); - stream.setPledgedSrcSize(pledgedSrcSize); + stream.setPledgedSrcSize(pledgedSrcSize.value()); return stream; } 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 67e2678..eda77ee 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/RefPrefixTest.java @@ -24,7 +24,7 @@ void roundTripsWithMatchingPrefix() { ZstdDecompressContext dctx = new ZstdDecompressContext()) { MemorySegment prefix = segmentOf(arena, prefixBytes); MemorySegment src = segmentOf(arena, dataBytes); - MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length)); + MemorySegment frame = arena.allocate(Zstd.compressBound(new ZstdByteSize(dataBytes.length)).value()); MemorySegment out = arena.allocate(dataBytes.length); // When @@ -94,7 +94,7 @@ void clearingPrefixWithNullCompressesPlainly() { ZstdCompressContext cctx = new ZstdCompressContext()) { MemorySegment prefix = segmentOf(arena, "a prior version of the text".getBytes()); MemorySegment src = segmentOf(arena, dataBytes); - MemorySegment frame = arena.allocate(Zstd.compressBound(dataBytes.length)); + MemorySegment frame = arena.allocate(Zstd.compressBound(new ZstdByteSize(dataBytes.length)).value()); // When — set then clear the prefix before compressing cctx.refPrefix(prefix); @@ -121,8 +121,8 @@ void prefixIsSingleUseAndDoesNotStickAcrossFrames() { ZstdCompressContext cctx = new ZstdCompressContext().level(new ZstdCompressionLevel(19))) { MemorySegment prefix = segmentOf(arena, random); MemorySegment src = segmentOf(arena, random); - MemorySegment first = arena.allocate(Zstd.compressBound(random.length)); - MemorySegment second = arena.allocate(Zstd.compressBound(random.length)); + MemorySegment first = arena.allocate(Zstd.compressBound(new ZstdByteSize(random.length)).value()); + MemorySegment second = arena.allocate(Zstd.compressBound(new ZstdByteSize(random.length)).value()); // When — first frame consumes the prefix; second is compressed with no re-set cctx.refPrefix(prefix); diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdByteSizeTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdByteSizeTest.java new file mode 100644 index 0000000..4eebc80 --- /dev/null +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdByteSizeTest.java @@ -0,0 +1,145 @@ +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 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 ZstdByteSizeTest { + + @Nested + class Construction { + + @ParameterizedTest + @ValueSource(longs = {0L, 1L, 1024L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1, Long.MAX_VALUE}) + void acceptsAnyNonNegativeValue(long value) { + // Given a non-negative byte size + // When wrapped + ZstdByteSize sut = new ZstdByteSize(value); + + // Then it carries that value + assertThat(sut.value()).isEqualTo(value); + } + } + + @Nested + class Validation { + + @ParameterizedTest + @ValueSource(longs = {-1L, Long.MIN_VALUE}) + void rejectsNegativeValues(long value) { + // Given a negative byte size + + // When wrapped + ThrowingCallable result = () -> new ZstdByteSize(value); + + // Then it is rejected before reaching native code + assertThatThrownBy(result) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(String.valueOf(value)); + } + } + + @Nested + class ToIntExact { + + @ParameterizedTest + @ValueSource(longs = {0L, 1L, 1024L, Integer.MAX_VALUE}) + void returnsTheValueForAnInRangeSize(long value) { + // Given a size that fits an int + ZstdByteSize sut = new ZstdByteSize(value); + + // When narrowed + int narrowed = sut.toIntExact(); + + // Then it is the same value as an int + assertThat(narrowed).isEqualTo((int) value); + } + + @Test + void throwsForAValueAboveIntegerMaxValue() { + // Given a size that exceeds the maximum array length + ZstdByteSize sut = new ZstdByteSize((long) Integer.MAX_VALUE + 1); + + // When narrowed + ThrowingCallable result = sut::toIntExact; + + // Then it overflows, matching Math.toIntExact's own contract + assertThatThrownBy(result).isInstanceOf(ArithmeticException.class); + } + } + + @Nested + class OfKiB { + + @Test + void multipliesByOneThousandTwentyFour() { + // Given a count of KiB + // When wrapped + ZstdByteSize sut = ZstdByteSize.ofKiB(8); + + // Then it holds the byte count + assertThat(sut).isEqualTo(new ZstdByteSize(8 * 1024L)); + } + + @Test + void rejectsANegativeCount() { + // Given a negative count of KiB + + // When wrapped + ThrowingCallable result = () -> ZstdByteSize.ofKiB(-1); + + // Then it is rejected, same as the constructor + assertThatThrownBy(result).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsOnOverflowInsteadOfWrapping() { + // Given a count so large that *1024 overflows a long + // When wrapped + ThrowingCallable result = () -> ZstdByteSize.ofKiB(Long.MAX_VALUE / 512); + + // Then it fails fast instead of silently wrapping to a bogus size + assertThatThrownBy(result).isInstanceOf(ArithmeticException.class); + } + } + + @Nested + class OfMiB { + + @Test + void multipliesByOneMebibyte() { + // Given a count of MiB + // When wrapped + ZstdByteSize sut = ZstdByteSize.ofMiB(4); + + // Then it holds the byte count + assertThat(sut).isEqualTo(new ZstdByteSize(4 * 1024L * 1024L)); + } + + @Test + void rejectsANegativeCount() { + // Given a negative count of MiB + + // When wrapped + ThrowingCallable result = () -> ZstdByteSize.ofMiB(-1); + + // Then it is rejected, same as the constructor + assertThatThrownBy(result).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void throwsOnOverflowInsteadOfWrapping() { + // Given a count so large that *1024*1024 overflows a long + // When wrapped + ThrowingCallable result = () -> ZstdByteSize.ofMiB(Long.MAX_VALUE / 1024); + + // Then it fails fast instead of silently wrapping to a bogus size + assertThatThrownBy(result).isInstanceOf(ArithmeticException.class); + } + } +} 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 26e097c..3847386 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdDictionaryTest.java @@ -32,7 +32,7 @@ void trainDictionary() { for (int i = 0; i < 4000; i++) { samples.add(sample(i)); } - sut = ZstdDictionary.train(samples, 16 * 1024); + sut = ZstdDictionary.train(samples, ZstdByteSize.ofKiB(16)); } @Nested @@ -41,7 +41,7 @@ class CoverTraining { @Test void fastCoverRoundTrips() { // Given a fast-COVER-trained dictionary - ZstdDictionary dict = ZstdDictionary.trainFastCover(samples, 16 * 1024); + ZstdDictionary dict = ZstdDictionary.trainFastCover(samples, ZstdByteSize.ofKiB(16)); assertThat(dict.size()).isGreaterThan(0); // Then records round-trip and compress smaller than dictionaryless @@ -62,7 +62,7 @@ void fastCoverRoundTrips() { @Test void coverRoundTrips() { // COVER is slower, so train on a subset to keep the test quick - ZstdDictionary dict = ZstdDictionary.trainCover(samples.subList(0, 1000), 8 * 1024); + ZstdDictionary dict = ZstdDictionary.trainCover(samples.subList(0, 1000), ZstdByteSize.ofKiB(8)); assertThat(dict.size()).isGreaterThan(0); byte[] sample = samples.get(5); @@ -76,7 +76,7 @@ void coverRoundTrips() { @Test void coverFailsWithoutSamples() { // When COVER-training on no samples - ThrowingCallable result = () -> ZstdDictionary.trainCover(List.of(), 4096); + ThrowingCallable result = () -> ZstdDictionary.trainCover(List.of(), new ZstdByteSize(4096)); // Then it fails fast with the empty-samples guard assertThatThrownBy(result) @@ -87,7 +87,7 @@ void coverFailsWithoutSamples() { @Test void fastCoverFailsWithoutSamples() { // When fast-COVER-training on no samples - ThrowingCallable result = () -> ZstdDictionary.trainFastCover(List.of(), 4096); + ThrowingCallable result = () -> ZstdDictionary.trainFastCover(List.of(), new ZstdByteSize(4096)); // Then it fails fast with the empty-samples guard assertThatThrownBy(result) @@ -106,7 +106,7 @@ void finalizesRawContentIntoUsableDictionary() { .getBytes(StandardCharsets.UTF_8); ZstdDictionary dict = - ZstdDictionary.finalizeFrom(content, samples, 16 * 1024, new ZstdCompressionLevel(0)); + 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); @@ -130,7 +130,7 @@ void trainedDictionaryHasHeader() { void finalizeFailsWithoutSamples() { // When finalizing raw content with no tuning samples ThrowingCallable result = - () -> ZstdDictionary.finalizeFrom(new byte[]{1, 2, 3}, List.of(), 4096, new ZstdCompressionLevel(0)); + () -> ZstdDictionary.finalizeFrom(new byte[]{1, 2, 3}, List.of(), new ZstdByteSize(4096), new ZstdCompressionLevel(0)); // Then it fails fast with the empty-samples guard assertThatThrownBy(result) @@ -169,7 +169,7 @@ void beatsDictionarylessOnTinyPayload() { @Test void failsWithoutSamples() { // When training on no samples - ThrowingCallable result = () -> ZstdDictionary.train(List.of(), 4096); + ThrowingCallable result = () -> ZstdDictionary.train(List.of(), new ZstdByteSize(4096)); // Then it fails fast with the empty-samples guard, before reaching ZDICT assertThatThrownBy(result) @@ -183,7 +183,7 @@ void surfacesTheNativeErrorNameWhenTrainingFails() { List tooFew = List.of(new byte[]{1, 2, 3}); // When training - ThrowingCallable result = () -> ZstdDictionary.train(tooFew, 112_640); + ThrowingCallable result = () -> ZstdDictionary.train(tooFew, new ZstdByteSize(112_640)); // Then the failure carries the native ZDICT error name, not an empty string assertThatThrownBy(result) 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 6ad1612..5d83f1c 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdErrorTest.java @@ -21,7 +21,7 @@ void reportsDstSizeTooSmall() { byte[] frame = Zstd.compress(PAYLOAD); // When it fails / Then the category is DST_SIZE_TOO_SMALL - ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, 1)); + ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, new ZstdByteSize(1))); assertThat(ex.code()).isEqualTo(ZstdErrorCode.DST_SIZE_TOO_SMALL); } @@ -31,7 +31,7 @@ void carriesTheNativeErrorNameAsTheMessage() { byte[] frame = Zstd.compress(PAYLOAD); // When it fails - ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, 1)); + ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(frame, new ZstdByteSize(1))); // Then the message is the descriptive native error name, not an empty string assertThat(ex).hasMessageContaining("too small"); @@ -52,7 +52,7 @@ void reportsANativeCategoryForGarbage() { byte[] garbage = "definitely not a zstd frame at all".getBytes(StandardCharsets.UTF_8); // Then the error carries a real native category, not UNKNOWN - ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(garbage, 1024)); + ZstdException ex = catchThrowableOfType(ZstdException.class, () -> Zstd.decompress(garbage, new ZstdByteSize(1024))); assertThat(ex.code()).isNotIn(ZstdErrorCode.UNKNOWN, ZstdErrorCode.NO_ERROR); } } 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 17fb64e..7087254 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdFrameTest.java @@ -83,7 +83,7 @@ class DecompressedBound { @Test void boundsTheDecompressedSize() { byte[] frame = Zstd.compress(PAYLOAD); - assertThat(ZstdFrame.decompressedBound(frame)).isGreaterThanOrEqualTo(PAYLOAD.length); + assertThat(ZstdFrame.decompressedBound(frame).value()).isGreaterThanOrEqualTo(PAYLOAD.length); } @Test @@ -106,7 +106,7 @@ void equalsContentSizeForSingleFrame() { byte[] frame = Zstd.compress(PAYLOAD); // Then the exact decompressed size is the payload length - assertThat(ZstdFrame.decompressedSize(frame)).isEqualTo(PAYLOAD.length); + assertThat(ZstdFrame.decompressedSize(frame)).isEqualTo(new ZstdByteSize(PAYLOAD.length)); } @Test @@ -119,7 +119,7 @@ void sumsAcrossConcatenatedFrames() throws Exception { // Then the total is the sum of both decompressed sizes assertThat(ZstdFrame.decompressedSize(both.toByteArray())) - .isEqualTo((long) PAYLOAD.length + second.length); + .isEqualTo(new ZstdByteSize((long) PAYLOAD.length + second.length)); } @Test @@ -275,7 +275,7 @@ void reportsContentSizeAndNoChecksumByDefault() { // Then it is a standard frame storing the content size, no checksum, no dict assertThat(header.frameType()).isEqualTo(ZstdFrameType.STANDARD); - assertThat(header.contentSize()).hasValue(PAYLOAD.length); + assertThat(header.contentSize()).hasValue(new ZstdByteSize(PAYLOAD.length)); assertThat(header.hasChecksum()).isFalse(); assertThat(header.dictId()).isEqualTo(ZstdDictionaryId.NONE); assertThat(header.windowSize()).isPositive(); diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdMemoryTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMemoryTest.java index dab3738..3c77f66 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdMemoryTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMemoryTest.java @@ -17,20 +17,21 @@ class Estimates { @Test void contextEstimatesArePositive() { - assertThat(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(3))).isPositive(); - assertThat(Zstd.estimateDecompressContextSize()).isPositive(); + assertThat(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(3)).value()).isPositive(); + assertThat(Zstd.estimateDecompressContextSize().value()).isPositive(); } @Test void higherLevelEstimatesAtLeastAsLarge() { - assertThat(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(19))) - .isGreaterThanOrEqualTo(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(1))); + assertThat(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(19)).value()) + .isGreaterThanOrEqualTo(Zstd.estimateCompressContextSize(new ZstdCompressionLevel(1)).value()); } @Test void dictionaryEstimatesArePositive() { - assertThat(Zstd.estimateCompressDictSize(64 * 1024, new ZstdCompressionLevel(3))).isPositive(); - assertThat(Zstd.estimateDecompressDictSize(64 * 1024)).isPositive(); + assertThat(Zstd.estimateCompressDictSize(ZstdByteSize.ofKiB(64), new ZstdCompressionLevel(3)).value()) + .isPositive(); + assertThat(Zstd.estimateDecompressDictSize(ZstdByteSize.ofKiB(64)).value()).isPositive(); } } @@ -40,9 +41,9 @@ class LiveSizes { @Test void contextSizeGrowsAfterUse() { try (ZstdCompressContext ctx = new ZstdCompressContext()) { - long before = ctx.sizeOf(); + long before = ctx.sizeOf().value(); ctx.compress(PAYLOAD); - long after = ctx.sizeOf(); + long after = ctx.sizeOf().value(); assertThat(before).isPositive(); assertThat(after).isGreaterThanOrEqualTo(before); } @@ -51,7 +52,7 @@ void contextSizeGrowsAfterUse() { @Test void decompressContextHasSize() { try (ZstdDecompressContext ctx = new ZstdDecompressContext()) { - assertThat(ctx.sizeOf()).isPositive(); + assertThat(ctx.sizeOf().value()).isPositive(); } } @@ -60,8 +61,8 @@ void digestedDictionariesHaveSize() { ZstdDictionary dict = trainDictionary(2000); try (ZstdCompressDictionary cdict = new ZstdCompressDictionary(dict); ZstdDecompressDictionary ddict = new ZstdDecompressDictionary(dict)) { - assertThat(cdict.sizeOf()).isPositive(); - assertThat(ddict.sizeOf()).isPositive(); + assertThat(cdict.sizeOf().value()).isPositive(); + assertThat(ddict.sizeOf().value()).isPositive(); } } @@ -69,8 +70,8 @@ void digestedDictionariesHaveSize() { void streamsReportContextSize() { try (ZstdCompressStream cs = new ZstdCompressStream(); ZstdDecompressStream ds = new ZstdDecompressStream()) { - assertThat(cs.sizeOf()).isPositive(); - assertThat(ds.sizeOf()).isPositive(); + assertThat(cs.sizeOf().value()).isPositive(); + assertThat(ds.sizeOf().value()).isPositive(); } } diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdMultithreadTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMultithreadTest.java index 4d52632..0dee9d1 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdMultithreadTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdMultithreadTest.java @@ -97,7 +97,7 @@ void roundTripsWithWorkers(int nbWorkers) { // When compressing a payload large enough to engage the workers MemorySegment src = segmentOf(arena, LARGE_PAYLOAD); - MemorySegment dst = arena.allocate(Zstd.compressBound(LARGE_PAYLOAD.length)); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(LARGE_PAYLOAD.length)).value()); ZstdStreamResult r = sut.compress(dst, src, ZstdEndDirective.END); frame = bytesOf(dst, r.bytesProduced()); } @@ -123,7 +123,7 @@ void roundTripsWithWorkers(int nbWorkers) throws IOException { } // Then the frame decompresses back to the original - assertThat(Zstd.decompress(sink.toByteArray(), LARGE_PAYLOAD.length)).isEqualTo(LARGE_PAYLOAD); + assertThat(Zstd.decompress(sink.toByteArray(), new ZstdByteSize(LARGE_PAYLOAD.length))).isEqualTo(LARGE_PAYLOAD); } } @@ -135,14 +135,14 @@ void workerPoolSurvivesReset() { // Given a context that compressed once without workers (baseline size) try (ZstdCompressContext sut = new ZstdCompressContext()) { sut.compress(LARGE_PAYLOAD); - long baseline = sut.sizeOf(); + long baseline = sut.sizeOf().value(); // When compressing with workers, then resetting session and parameters sut.parameter(ZstdCompressParameter.NB_WORKERS, 2); sut.compress(LARGE_PAYLOAD); - long afterMultithread = sut.sizeOf(); + long afterMultithread = sut.sizeOf().value(); sut.reset(ZstdResetDirective.SESSION_AND_PARAMETERS); - long afterReset = sut.sizeOf(); + long afterReset = sut.sizeOf().value(); // Then the worker pool and job buffers were allocated by the MT // compress, and reset did NOT release them — only close() does. 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 4f34bad..5726aca 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdParameterTest.java @@ -47,7 +47,7 @@ void rejectsCorruptedChecksummedFrame() { frame[frame.length / 2] ^= 0x01; // When decompressed - ThrowingCallable result = () -> Zstd.decompress(frame, PAYLOAD.length); + ThrowingCallable result = () -> Zstd.decompress(frame, new ZstdByteSize(PAYLOAD.length)); // Then the integrity check fails assertThatThrownBy(result).isInstanceOf(ZstdException.class); diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentStreamTest.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentStreamTest.java index 22f1bbf..dbb9179 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentStreamTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentStreamTest.java @@ -27,7 +27,7 @@ void roundTripsInOneCallEach() { ZstdDecompressStream ds = new ZstdDecompressStream()) { MemorySegment src = segmentOf(arena, original); - MemorySegment dst = arena.allocate(Zstd.compressBound(original.length)); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(original.length)).value()); // When compressed in one END step ZstdStreamResult c = cs.compress(dst, src, ZstdEndDirective.END); @@ -97,7 +97,7 @@ void tracksByteCounters() { ZstdCompressStream cs = new ZstdCompressStream()) { MemorySegment src = segmentOf(arena, original); - MemorySegment dst = arena.allocate(Zstd.compressBound(original.length)); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(original.length)).value()); // fresh stream: nothing moved yet assertThat(cs.progress().consumed()).isZero(); @@ -127,7 +127,7 @@ void roundTripsAgainstDictionary() { ZstdDecompressStream ds = new ZstdDecompressStream(dict)) { MemorySegment src = segmentOf(arena, sample); - MemorySegment dst = arena.allocate(Zstd.compressBound(sample.length)); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(sample.length)).value()); ZstdStreamResult c = cs.compress(dst, src, ZstdEndDirective.END); MemorySegment out = arena.allocate(sample.length); 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 c121e50..a7a18ec 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdSegmentTest.java @@ -29,17 +29,17 @@ void roundTripsNativeToNative() { ZstdDecompressContext dctx = new ZstdDecompressContext()) { MemorySegment src = segmentOf(arena, original); - MemorySegment dst = arena.allocate(Zstd.compressBound(original.length)); + MemorySegment dst = arena.allocate(Zstd.compressBound(new ZstdByteSize(original.length)).value()); // When compressed into a caller-sized destination long packedLen = cctx.compress(dst, src); MemorySegment frame = dst.asSlice(0, packedLen); // Then the frame header reports the original size, and it decodes back - long outLen = Zstd.decompressedSize(frame); - assertThat(outLen).isEqualTo(original.length); + ZstdByteSize outLen = Zstd.decompressedSize(frame); + assertThat(outLen).isEqualTo(new ZstdByteSize(original.length)); - MemorySegment out = arena.allocate(outLen); + MemorySegment out = arena.allocate(outLen.value()); long written = dctx.decompress(out, frame); assertThat(bytesOf(out, written)).isEqualTo(original); } 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 5f15ad6..d9c1011 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdStreamTest.java @@ -78,7 +78,7 @@ void streamOutputDecodesWithOneShot() throws IOException { byte[] frame = streamCompress(original, new ZstdCompressionLevel(6)); // Then the one-shot decompressor reads it (frame stores no size -> give a bound) - assertThat(Zstd.decompress(frame, original.length)).isEqualTo(original); + assertThat(Zstd.decompress(frame, new ZstdByteSize(original.length))).isEqualTo(original); } @Test @@ -188,13 +188,13 @@ void recordsContentSizeInTheFrame() throws IOException { // Given a stream told the exact total up front byte[] original = "pledged payload ".repeat(300).getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream sink = new ByteArrayOutputStream(); - try (ZstdOutputStream zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), original.length)) { + try (ZstdOutputStream zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), new ZstdByteSize(original.length))) { zout.write(original); } byte[] frame = sink.toByteArray(); // Then the frame header carries the size, so size-less decompress works - assertThat(ZstdFrame.header(frame).contentSize()).hasValue(original.length); + assertThat(ZstdFrame.header(frame).contentSize()).hasValue(new ZstdByteSize(original.length)); assertThat(Zstd.decompress(frame)).isEqualTo(original); } @@ -221,7 +221,7 @@ void pledgedFrameDecodesZeroCopyIntoArenaInOneShot() throws IOException { // Given a streamed frame that pledged its total up front byte[] original = "pledge enables zero-copy ".repeat(500).getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream sink = new ByteArrayOutputStream(); - try (ZstdOutputStream zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), original.length)) { + try (ZstdOutputStream zout = ZstdOutputStream.withPledgedSize(sink, new ZstdCompressionLevel(6), new ZstdByteSize(original.length))) { zout.write(original); } byte[] frame = sink.toByteArray(); 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 b856941..68c702a 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTest.java @@ -100,10 +100,10 @@ class CompressBound { @ValueSource(longs = {0, 1, 1024, 1_000_000}) void neverUndersizesTheDestination(long srcSize) { // When the worst-case bound is queried - long bound = Zstd.compressBound(srcSize); + ZstdByteSize bound = Zstd.compressBound(new ZstdByteSize(srcSize)); // Then it is at least the input size - assertThat(bound).isGreaterThanOrEqualTo(srcSize); + assertThat(bound.value()).isGreaterThanOrEqualTo(srcSize); } } @@ -128,7 +128,7 @@ void rejectsOversizedFrameForBuffer() { byte[] frame = Zstd.compress("0123456789".getBytes(StandardCharsets.UTF_8)); // When decompressing into too small a buffer - ThrowingCallable result = () -> Zstd.decompress(frame, 1); + ThrowingCallable result = () -> Zstd.decompress(frame, new ZstdByteSize(1)); // Then it fails assertThatThrownBy(result).isInstanceOf(ZstdException.class); @@ -267,7 +267,7 @@ void boundedDecompressRefusesADecompressionBomb() { assertThat(bomb).hasSizeLessThan(1024); // huge amplification ratio // When decompressed with a small bound (the safe path for untrusted input) - ThrowingCallable result = () -> Zstd.decompress(bomb, 64 * 1024); + ThrowingCallable result = () -> Zstd.decompress(bomb, ZstdByteSize.ofKiB(64)); // Then it is refused instead of allocating the full expansion assertThatThrownBy(result).isInstanceOf(ZstdException.class); @@ -300,6 +300,23 @@ void rejectsAFrameDeclaringMoreThanArrayMaxWithoutLeakingArithmeticException() { .hasMessageContaining("exceeds the maximum array length"); } + @Test + void rejectsAFrameDeclaringAContentSizeWithTheSignBitSet() { + // Given a frame header whose 8-byte Frame_Content_Size field reads as + // Long.MIN_VALUE — zstd stores it as `unsigned long long`, so this is the + // real value 2^63, not a negative size + byte[] frame = frameHeaderDeclaringContentSize(Long.MIN_VALUE); + + // When decompressed via the size-trusting overload + ThrowingCallable result = () -> Zstd.decompress(frame); + + // Then it fails with a ZstdException, not an IllegalArgumentException + // leaking out of ZstdByteSize's own non-negative guard + assertThatThrownBy(result) + .isInstanceOf(ZstdException.class) + .hasMessageContaining("not a valid zstd frame content size"); + } + // A minimal single-segment zstd frame header (magic + descriptor + 8-byte // Frame_Content_Size) declaring `contentSize`; enough for the content-size // read, which happens before any block is decoded. diff --git a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTestSupport.java b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTestSupport.java index d43fcf8..3afe6bc 100644 --- a/zstd/src/test/java/io/github/dfa1/zstd/ZstdTestSupport.java +++ b/zstd/src/test/java/io/github/dfa1/zstd/ZstdTestSupport.java @@ -56,7 +56,7 @@ static ZstdDictionary trainDictionary(int sampleCount) { for (int i = 0; i < sampleCount; i++) { samples.add(sample(i)); } - return ZstdDictionary.train(samples, 8 * 1024); + return ZstdDictionary.train(samples, ZstdByteSize.ofKiB(8)); } /// `n` pseudo-random bytes from a fixed `seed`, so a failure reproduces.