Consolidate the pending bug fixes: 8 open PRs plus the fz-branch findings - #144
Merged
Conversation
…ings Bug fixes only. Nothing here changes the compressed format or an interface signature, and every algorithm's output is byte-identical to master's (verified over 31 dataset/algorithm/error-bound combinations, plus decompressing files master produced). From the open pull requests, reviewed line by line: #131 Config::load read one byte past the end of the config blob #132 bounds-check the whole decompression path (see the two skips below) #133 shift-by-64 UB for a single-symbol Huffman tree #134 bounds-check HuffmanEncoderV2 tree loading #135 bounds-check XtcBasedEncoder's magicInts index; plug a leaked buffer #137 remaining_length accounting after Huffman decode in both predictors #138 non-finite float to int64_t cast UB in LinearQuantizer #139 scratch buffers leaked when compression throws; OMP chunk capacity was missing room for the size header Lossless_zstd writes Two parts of #132 are deliberately not taken: - It moves the quant_inds count from after the encoder's tree to before it so the tree is immediately followed by its encoded stream. That is a compressed format change and it is not versioned, so files written by any released SZ3 fail to decode. The bound it was buying is recovered instead by letting the caller supply it: HuffmanEncoder::set_decode_bound(), called by SZGenericCompressor once it has consumed the count, and by both predictors. load() no longer guesses the bound from its own buffer -- the tree and the stream are not required to share a buffer, and test_encoder.cpp puts them in separate ones. - It reinterprets LosslessInterface::decompress's `dstLen` as an input capacity when the caller provides the buffer. The declared contract is that it is an output, and callers do pass uninitialised values, so this fails nondeterministically (it broke LosslessTest.LosslessBypass here). The self-allocating branch, where a non-zero value is opt-in, is kept. Also fixed while reviewing: - HuffmanEncoderV2 sized its dense tables from an unbounded `maxval` read straight from the stream; #134 bounds the node count but not this. - HuffmanEncoderV2's node count is an int holding a value read as 64-bit, so the new bound is checked with an explicit sign test rather than an implicit conversion. - test_lossless.cpp passed an uninitialised size to decompress(). From the fz branch, verified to reproduce here: Lossless_bypass::compress ignored its destination capacity, so any payload larger than the caller's buffer was an unconditional heap overflow; TimeSeriesDecomposition violated its error bound 1.94x on the null-reference-frame path; ArithmeticEncoder sign-extended a shift, corrupting about half of all streams; HuffmanEncoder's stateNum narrowing overflowed on a wide bin range; RunlengthEncoder and two decompositions had no size_est(), so the compressor sized its buffer from 0; KmeansUtil had reserve+operator[] UB and an off-by-one read; the HDF5 filter sized its buffer below SZ_compress's own minimum; 24 headers were not self-contained under libstdc++ or libc++; .gitignore's bare `test` pattern hid every test file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Taking the fz-branch fixes file by file overwrote them. The magicInts index and the packed-data size both come from the compressed stream and were used unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR consolidates a large set of hardening and correctness bug fixes across the SZ3 library (core decompression safety, lossless codecs, predictors/encoders, and portability via self-contained headers), while aiming to keep formats and outputs byte-identical to master.
Changes:
- Adds systematic bounds checks and fail-closed behavior on multiple decompression paths to prevent OOB reads/writes on corrupted inputs.
- Fixes several correctness/UB issues (e.g., NaN/Inf quantization cast UB, Huffman single-symbol shift UB, arithmetic decoder sign-extension).
- Improves robustness/portability: RAII for scratch buffers on throwing paths, adds missing standard/project includes, adjusts
.gitignore.
Reviewed changes
Copilot reviewed 40 out of 41 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/test/modules/test_lossless.cpp | Initializes output size to avoid passing garbage into decompress() tests. |
| tools/H5Z-SZ3/src/H5Z_SZ3.cpp | Sizes HDF5 filter buffer using SZ3’s size bound to avoid too-small chunks aborting. |
| include/SZ3/utils/Statistic.hpp | Adds missing standard headers for self-contained compilation. |
| include/SZ3/utils/Sample.hpp | Adds missing <cassert> include and keeps project include ordering correct. |
| include/SZ3/utils/QuantOptimization.hpp | Adds missing <cmath> / def.hpp includes for self-contained compilation. |
| include/SZ3/utils/MemoryUtil.hpp | Replaces release-stripped asserts with exceptions for bounded reads. |
| include/SZ3/utils/KmeansUtil.hpp | Fixes UB in sampling (reserve + operator[], distribution upper-bound). |
| include/SZ3/utils/Iterator.hpp | Adds missing project header include for self-contained compilation. |
| include/SZ3/utils/Extraction.hpp | Adds missing includes and project headers for self-contained compilation. |
| include/SZ3/utils/Config.hpp | Adds bounded Config::load overload and validates dims/num consistency. |
| include/SZ3/utils/BlockwiseIterator.hpp | Adds block_data::values() to materialize unpadded data when padding is used. |
| include/SZ3/quantizer/Quantizer.hpp | Adds quantizer_size_est() helper to optionally use quantizer size estimates. |
| include/SZ3/quantizer/LinearQuantizer.hpp | Fixes float→int UB for NaN/Inf/overflow; bounds unpredictable reads/allocations. |
| include/SZ3/preprocessor/Transpose.hpp | Adds missing standard headers for self-contained compilation. |
| include/SZ3/preprocessor/PreFilter.hpp | Adds missing standard headers for self-contained compilation. |
| include/SZ3/predictor/RegressionPredictor.hpp | Fixes remaining_length accounting and bounds coefficient stream consumption. |
| include/SZ3/predictor/LorenzoPredictor.hpp | Avoids pointer-overflow UB by using pointer subtraction for neighbor access. |
| include/SZ3/predictor/ComposedPredictor.hpp | Bounds predictor selection indices and fixes remaining_length accounting. |
| include/SZ3/lossless/Lossless.hpp | Adds missing includes for interface header self-containment. |
| include/SZ3/lossless/Lossless_zstd.hpp | Hardened zstd decompress: header checks, bounded self-allocation, exact-size enforcement, RAII on failure. |
| include/SZ3/lossless/Lossless_bypass.hpp | Adds dstCap check on compress; bounds self-allocation and allocation failure checks on decompress. |
| include/SZ3/encoder/XtcBasedEncoder.hpp | Adds missing headers/includes needed for compilation/self-containment. |
| include/SZ3/encoder/RunlengthEncoder.hpp | Adds size_est() and tracks bin count for buffer sizing. |
| include/SZ3/encoder/HuffmanEncoderV2.hpp | Bounds-checks serialized tree header/bitstream and prevents untrusted large allocations. |
| include/SZ3/encoder/HuffmanEncoder.hpp | Adds decode bounding hook; hardens decode/tree parsing; fixes single-symbol shift UB; bounds tree indices/state table sizing. |
| include/SZ3/encoder/ArithmeticEncoder.hpp | Fixes sign-extension in value extraction by using unsigned shift. |
| include/SZ3/decomposition/TimeSeriesDecomposition.hpp | Fixes timestep-0 reconstruction usage when data_ts0 == nullptr to maintain error-bound correctness. |
| include/SZ3/decomposition/SZBioMDXtcDecomposition.hpp | Adds missing standard header include. |
| include/SZ3/decomposition/NoPredictionDecomposition.hpp | Adds a size_est() override for better buffer sizing. |
| include/SZ3/decomposition/InterpolationDecomposition.hpp | Adds stored-dimension validation on decompress and a size_est() override. |
| include/SZ3/decomposition/Decomposition.hpp | Adds missing includes to keep header self-contained. |
| include/SZ3/decomposition/BlockwiseDecomposition.hpp | Fixes includes and ordering for self-contained compilation. |
| include/SZ3/compressor/SZGenericCompressor.hpp | Adds RAII for scratch buffers, bounds internal lossless buffer allocation, and threads decode bounds to encoders when supported. |
| include/SZ3/compressor/specialized/SZTruncateCompressor.hpp | Uses RAII for scratch buffer to avoid leaks on thrown compression. |
| include/SZ3/compressor/specialized/SZExaaltCompressor.hpp | Adds missing includes and adjusts include ordering for self-containment. |
| include/SZ3/api/sz.hpp | Adds RAII to heap-owning compress API and bounds-checks decompression header/payload/config blob. |
| include/SZ3/api/impl/SZImplOMP.hpp | Adds OMP-path bounds checks and RAII for per-thread buffers; accounts for lossless header sizing. |
| include/SZ3/api/impl/SZDispatcher.hpp | Adds RAII to avoid leaks on thrown zstd fallback compression. |
| include/SZ3/api/impl/SZAlgoInterp.hpp | Adds missing include for self-contained compilation. |
| include/SZ3/api/impl/SZAlgoBioMD.hpp | Reorders/includes required headers explicitly for self-contained compilation. |
| .gitignore | Fixes test pattern to avoid hiding tools/test/. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+75
to
+78
| void read(T1 *array, size_t num_elements, uchar const *&compressed_data_pos, size_t &remaining_length) { | ||
| assert(num_elements * sizeof(T1) <= remaining_length); | ||
| if (num_elements * sizeof(T1) > remaining_length) { | ||
| throw std::invalid_argument("SZ3: compressed stream is truncated"); | ||
| } |
Comment on lines
+369
to
+373
| const unsigned char* const c0 = c; | ||
| auto require = [&](size_t n) { | ||
| if (cmpSize - static_cast<size_t>(c - c0) < n) | ||
| throw std::out_of_range("SZ3 Config::load: read past the end of the config"); | ||
| }; |
| // } | ||
| // std::cout << std::endl; | ||
| std::uniform_int_distribution<> dis2(0, num); | ||
| std::uniform_int_distribution<> dis2(0, static_cast<int>(num) - 1); |
Comment on lines
+165
to
+168
| size_t size_est() override { | ||
| return sizeof(original_dimensions) + sizeof(blocksize) + sizeof(interp_id) + sizeof(direction_sequence_id) + | ||
| quantizer_size_est(quantizer) + 128; | ||
| } |
num_elements * sizeof(T1) can overflow on its own, which is the computation the check is supposed to guard. Matches the scalar overload and PR #132. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It bounds that buffer by SZ_compress_size_bound, which is the size of the *output* buffer. compress() sizes the internal one as max(1000, 2 * (decomposition.size_est() + encoder.size_est() + sizeof(Q) * bins)), so for a 64-bit bin type it is far larger and valid streams are rejected. Every module in this tree emits int bins, so the bound happens to hold and these tests cannot reach it -- the fz branch has three modules that do (BitplaneEncoder, BitTruncationQuantizer, FixedPointQuantizer) and all three failed. A corrupted declared size is still caught, after the allocation, by the zstd frame check and the size comparison this PR adds to Lossless_zstd::decompress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #132 rejects N outside [1, 4] and dimensions whose product differs from the element count. Both hold for a config read from the end of a compressed stream, and neither holds for the HDF5 filter's cd_values: those carry placeholder zeros that set_local fills in later, as cdvalueHelper.py says in as many words. The check killed the whole filter -- 80 of cesm-atm's 160 integration cases aborted with "invalid number of dimensions", across every algorithm. The single-argument overload, which is what the filter and the OpenMP path use, now says so explicitly and skips the content checks; the bounded overload used by SZ_decompress keeps them. Verified against the real cesm-atm field through the HDF5 filter: five algorithms pass, and removing the guard reproduces the abort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The filter has to size its output buffer from SZ_compress_size_bound rather than from the raw chunk size, and nothing in the suite exercised that: a chunk small enough for the compressed block to exceed it never appeared. This adds a third chunk mode that asks for 8-element chunks, which the pre-fix filter rejects with "buffer not large enough" on every algorithm. The mode is restricted to a leading slice of at most 4096 chunks. Over a whole field it would mean tens of millions of chunks, and the HDF5 chunk index alone takes the process past a CI runner's memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ayzk
added a commit
that referenced
this pull request
Sep 1, 2026
master now carries the consolidated bug fixes (#144). Three conflicts, all in commentary: fz-dev had a stale one-line comment above Config::load's fuller doc block (dropped), one condition differed only in line wrapping (took master's single line, which fits the 120-column limit), and fz-dev's note on the dropped decompression-buffer bound names the modules that actually emit 64-bit bins, so it is the accurate wording here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ayzk
added a commit
that referenced
this pull request
Sep 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug fixes only. Nothing here changes the compressed format or an interface signature, and every algorithm's output is byte-identical to
master's -- verified over 31 combinations of dataset (mirandavelocityx.d64float64, cesm-atmODV_bcar1float32, the in-tree smoke file), algorithm and error bound, plus decompressing filesmasteritself produced.Supersedes #143.
The open pull requests, reviewed line by line
Config::loadcomputed the end of the config blob asc + confSizeafterchad already advanced past the length prefix, one byte too far.save()includes the prefix inconfSize, so the end isc0 + confSize. The last optional-field guard then fires with one byte left and reads a field that is not there -- past the end of the buffer, since the config is the last thing in the stream.SZ_decompress(which previously ignored thecmpSizeit was given entirely), a boundedConfig::loadoverload withN/bit-width/dims-product validation,Lossless_zstd(including returning a zstd error code as a size), Huffman tree loading,unpad_treechild indices, predictor selection indices,recover_unpred,unpred_sizebeforeresize, OMP thread counts and per-thread sizes, and RAII for buffers freed on the throwing paths.out1 << (64 - len)withlen == 0-- a single-symbol Huffman tree, which a constant field produces -- shifts a 64-bit value by 64.HuffmanEncoderV2::loadAsDFSOrderread a fixed header and a DFS bitstream with no bounds at all,reserve(tree.n << 1)overflowed, and it advanced the cursor without ever decrementingremaining_length.smallIdx, read from the compressed stream, indexed the fixed-sizemagicIntstable unchecked; the firstmallocinXtcBasedEncoderwas overwritten and leaked;Lossless_bypassdid not check itsmalloc.RegressionPredictor::loaddebitedremaining_lengthby the decoded bin count whiledecode()advances by the much smaller encoded byte count, so the budget ran out early;ComposedPredictor::loadhad the mirror-image bug and never debited it at all, leaving the bound too loose.static_cast<int64_t>(fabs(diff) * error_bound_reciprocal)is UB for NaN, infinities and huge magnitudes.free()d at the end of a function that can throw; and the OMP path's per-chunk capacity omitted the size headerLossless_zstd::compresswrites, so a poorly compressible chunk threw.The two parts of #132 not taken
The compressed format change. #132 moves the
quant_indscount from after the encoder's serialized tree to before it, so the tree is immediately followed by its encoded stream and the boundload()records is exact. That is a format change andSZ3_DATA_VERSIONis not bumped, so the version check passes and a file written by any released SZ3 fails with a misleadingSZ3 Huffman: invalid node count. Verified by decompressing amaster-produced file with #132's build.The bound it was buying is recovered without touching the format, by letting the caller supply it instead of having
load()infer it:SZGenericCompressorcalls it once it has consumed the count field, and both predictors call it because their tree and stream are contiguous.load()no longer records a bound at all: the tree and the encoded stream are not required to share a buffer, andtools/test/modules/test_encoder.cppdeliberately puts them in separate ones -- inferring the bound there made that test fail.The
dstLenreinterpretation. #132 treatsLosslessInterface::decompress'sdstLenas an input capacity when the caller supplies the buffer. The declared contract is that it is an output, and callers do pass uninitialised values --LosslessTest.LosslessBypassfails with it, andLosslessTest.LosslessZstdonly passed because the garbage happened to be large. The self-allocating branch, where a non-zero incoming value is opt-in and zero means no bound, is kept.Gaps found while reviewing, fixed here
HuffmanEncoderV2sizes its dense tables withveccode.resize(tree.maxval), andmaxvalis read straight from the stream. Bounds-check HuffmanEncoderV2 tree loading against corrupted input #134 bounds the node count but not this, leaving an unbounded allocation. Bounded by the encoder's own invariant (preprocess_encodeonly leavesusemp == 0whilemaxval < 1 << 28).tree.nis anintholding a value read as 64-bit, so Bounds-check HuffmanEncoderV2 tree loading against corrupted input #134's new bound is checked with an explicit sign test rather than letting the comparison convert it (which also silenced a-Wsign-comparewarning the PR introduced).tools/test/modules/test_lossless.cpppassed an uninitialised size todecompress().compressed block to exceed the raw chunk size never appeared.
test_h5_filter.pygains a thirdchunk mode that asks for 8-element chunks, which the pre-fix filter rejects with
buffer not large enoughon every algorithm. The mode is restricted to a leading slice of atmost 4096 chunks -- over a whole 280M-element field it would mean 35M chunks, and the HDF5 chunk
index alone takes the process to 15.3 GiB of a runner's 16 GiB. Measured in a 16 GiB container at
hacc's exact size: 9.6 GiB and passing.
From the
fzbranch, verified to reproduce hereLossless_bypass::compressignored its destination capacity andmemcpy'd the payload regardless -- an unconditional heap overflow whenever the payload is larger than the caller's buffer.ALGO_BIOMDXTCis the one algorithm that pairs its codec with bypass, so nothing shrinks the payload; AddressSanitizer on Linux caught it as a 59599-byte overflow, glibc reportedmunmap_chunk(): invalid pointer, and macOS's allocator did not abort at all.TimeSeriesDecompositionviolated its error bound 1.94x withdata_ts0 == nullptr:block_data's compress-side path has no write-back, so timestep 0 was never updated with its reconstruction whiledecompress()predicted from its own. 0.997x after the fix.ArithmeticEncodersign-extendedbytesToInt64_bigEndian(bytes) >> 20, pushing the value outside the 44-bitMAX_CODEwindow. A 60-stream sweep gave 30 correct, 23 wrong, 7 SIGSEGV.HuffmanEncoder'sstateNum = max - offset + 2narrows toint, so a wide bin range goes negative and the state-tablemallocis UB.RunlengthEncoder,NoPredictionDecompositionandInterpolationDecompositionhad nosize_est(), soSZGenericCompressorsized its buffer from 0 while the quantizer's unpredictable list could be arbitrarily large.KmeansUtilhadreserve()followed byoperator[]writes, anduniform_int_distribution(0, num)indexingdata[num]. Live inmdzwhendims[1] > 5000.SZ_compress's own minimum, so an explicit chunk of a few hundred elements aborted.fzonce a new test file did..gitignore's baretestpattern matchedtools/test/, hiding every test file from git.Verification
BUILD_TESTING=ON+BUILD_H5Z_FILTER=ON: 0 errors, 0 warnings, all tests passmaster; everymaster-produced file decompresses to the same bytesGenerated with Claude Code