From 19d806eb0fd4c48ddb0522528af6fac44438e11f Mon Sep 17 00:00:00 2001 From: Saleem Malik Date: Wed, 29 Jul 2026 18:09:24 +0530 Subject: [PATCH 01/94] reject out-of-range numFrames in seekable seek table loader --- .../seekable_format/tests/seekable_tests.c | 23 +++++++++++++++++++ contrib/seekable_format/zstdseek_decompress.c | 7 ++++++ 2 files changed, 30 insertions(+) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index 809ea6425eb..b535f5571ef 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -6,6 +6,7 @@ #include #include "../zstd_seekable.h" +#include "zstd_errors.h" /* ZSTD_seekable_customFile implementation that reads/seeks a buffer while keeping track of total bytes read */ @@ -365,6 +366,28 @@ int main(int argc, const char** argv) } printf("Success!\n"); + printf("Test %u - reject seek table with numFrames beyond format max: ", testNb++); + { /* A corrupt footer claiming numFrames > ZSTD_SEEKABLE_MAXFRAMES must be + * rejected up front: such a value overflows the seek table size math. */ + const uint8_t compressed_data[17] = { + 0x5e, 0x2a, 0x4d, 0x18, /* skippable frame magic (| 0xE) */ + 0x09, 0x00, 0x00, 0x00, /* skippable frame size = 9 */ + 0x01, 0x00, 0x00, 0x08, /* footer: numFrames = 0x08000001 */ + 0x00, /* footer: seek table descriptor */ + 0xb1, 0xea, 0x92, 0x8f, /* seekable magic number */ + }; + + ZSTD_seekable* const stream = ZSTD_seekable_create(); + assert(stream != NULL); + { size_t const status = ZSTD_seekable_initBuff(stream, compressed_data, sizeof(compressed_data)); + if (ZSTD_getErrorCode(status) != ZSTD_error_corruption_detected) { + ZSTD_seekable_free(stream); + goto _test_error; + } } + ZSTD_seekable_free(stream); + } + printf("Success!\n"); + /* TODO: Add more tests */ printf("Finished tests\n"); return 0; diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index ab9088a1061..dc66c35f7f9 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -397,6 +397,13 @@ static size_t ZSTD_seekable_loadSeekTable(ZSTD_seekable* zs) U32 const frameSize = tableSize + ZSTD_seekTableFooterSize + ZSTD_SKIPPABLEHEADERSIZE; U32 remaining = frameSize - ZSTD_seekTableFooterSize; /* don't need to re-read footer */ + + /* numFrames is bounded by the format (the compressor never writes more + * than ZSTD_SEEKABLE_MAXFRAMES). A larger value can only come from a + * corrupt footer and overflows the tableSize/frameSize math above, so + * reject it before those values are used to seek or size the table. */ + if (numFrames > ZSTD_SEEKABLE_MAXFRAMES) return ERROR(corruption_detected); + { U32 const toRead = MIN(remaining, SEEKABLE_BUFF_SIZE); CHECK_IO(src.seek(src.opaque, -(S64)frameSize, SEEK_END)); CHECK_IO(src.read(src.opaque, zs->inBuff, toRead)); From 7d777bbfe2613640ba9517d8af2fbbd574c2fa24 Mon Sep 17 00:00:00 2001 From: nileshpatil6 Date: Fri, 21 Aug 2026 12:13:21 +0530 Subject: [PATCH 02/94] Reject a too large block in ZSTD_compressSequencesAndLiterals() ZSTD_compressSequences() validates every block through determine_blockSize(), which in explicit-delimiter mode rejects a block whose regenerated size exceeds cctx->blockSizeMax: if (explicitBlockSize > blockSize) RETURN_ERROR(externalSequences_invalid, "sequences incorrectly define a too large block"); ZSTD_compressSequencesAndLiterals_internal() only supports explicit delimiters, but does not perform that check. It calls ZSTD_get1BlockSummary() and validates block.litSize against litSize only, so sequences describing an over-sized block are accepted and the function returns success while emitting a block that exceeds the maximum block size the format allows. The two APIs disagree on identical input: ZSTD_compressSequences -> External sequences are not valid ZSTD_compressSequencesAndLiterals -> SUCCESS (cSize = 36) For a large enough block the resulting frame is not decodable, so the caller receives a success return for bytes that cannot be read back. The guard was added for the sibling path in 87dcd33 (2022). The AndLiterals pipeline was split out later, in 14a21e4 (2024), without carrying it over. --- lib/compress/zstd_compress.c | 1 + tests/fuzzer.c | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index c06f2e1bb98..52c00d275aa 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -8033,6 +8033,7 @@ ZSTD_compressSequencesAndLiterals_internal(ZSTD_CCtx* cctx, FORWARD_IF_ERROR(block.nbSequences, "Error while trying to determine nb of sequences for a block"); assert(block.nbSequences <= nbSequences); RETURN_ERROR_IF(block.litSize > litSize, externalSequences_invalid, "discrepancy: Sequences require more literals than present in buffer"); + RETURN_ERROR_IF(block.blockSize > cctx->blockSizeMax, externalSequences_invalid, "sequences incorrectly define a too large block"); ZSTD_resetSeqStore(&cctx->seqStore); conversionStatus = ZSTD_convertBlockSequences(cctx, diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 7b7c9d66631..3ff1d0fa1c9 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4365,6 +4365,49 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_compressSequencesAndLiterals : reject a too large block : ", testNb++); + { + /* A single sequence, followed by an explicit block delimiter, describing + * one block whose regenerated size exceeds ZSTD_BLOCKSIZE_MAX. + * ZSTD_compressSequences() already rejects this in determine_blockSize(), + * so the AndLiterals() variant must reject it too. Otherwise it reports + * success while emitting a block that violates the format. */ + const size_t litLen = 16; + const size_t blockSize = ZSTD_BLOCKSIZE_MAX + 1; + BYTE* const dst = (BYTE*)compressedBuffer; + size_t const dstCapacity = ZSTD_compressBound(blockSize); + BYTE litBuffer[32]; + ZSTD_Sequence seqs[2]; + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + size_t cSize; + size_t i; + + assert(cctx != NULL); + for (i = 0; i < sizeof(litBuffer); i++) litBuffer[i] = (BYTE)('a' + (i % 3)); + + seqs[0].offset = 1; + seqs[0].litLength = (U32)litLen; + seqs[0].matchLength = (U32)(blockSize - litLen); + seqs[0].rep = 0; + seqs[1].offset = 0; /* block delimiter */ + seqs[1].litLength = 0; + seqs[1].matchLength = 0; + seqs[1].rep = 0; + + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_blockDelimiters, ZSTD_sf_explicitBlockDelimiters)); + cSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, + seqs, 2, + litBuffer, litLen, sizeof(litBuffer), + blockSize); + if (!ZSTD_isError(cSize)) { + DISPLAY("ZSTD_compressSequencesAndLiterals() should have failed: sequences define a block larger than ZSTD_BLOCKSIZE_MAX\n"); + ZSTD_freeCCtx(cctx); + goto _output_error; + } + ZSTD_freeCCtx(cctx); + } + DISPLAYLEVEL(3, "OK \n"); + /* Multiple blocks of zeros test */ #define LONGZEROSLENGTH 1000000 /* 1MB of zeros */ DISPLAYLEVEL(3, "test%3i : compress %u zeroes : ", testNb++, LONGZEROSLENGTH); From bf7504bf1a65e48faa14f0293b1a961a81534b8e Mon Sep 17 00:00:00 2001 From: nileshpatil6 Date: Sat, 22 Aug 2026 21:55:33 +0530 Subject: [PATCH 03/94] tests: rename cSize in the new test to avoid shadowing basicUnitTests() already declares size_t cSize, so the inner declaration tripped -Wshadow and failed builds using MOREFLAGS=-Werror. --- tests/fuzzer.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 3ff1d0fa1c9..2fd92a54ec7 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4379,7 +4379,7 @@ static int basicUnitTests(U32 const seed, double compressibility) BYTE litBuffer[32]; ZSTD_Sequence seqs[2]; ZSTD_CCtx* const cctx = ZSTD_createCCtx(); - size_t cSize; + size_t cSizeTooLarge; size_t i; assert(cctx != NULL); @@ -4395,11 +4395,11 @@ static int basicUnitTests(U32 const seed, double compressibility) seqs[1].rep = 0; CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_blockDelimiters, ZSTD_sf_explicitBlockDelimiters)); - cSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, + cSizeTooLarge = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, seqs, 2, litBuffer, litLen, sizeof(litBuffer), blockSize); - if (!ZSTD_isError(cSize)) { + if (!ZSTD_isError(cSizeTooLarge)) { DISPLAY("ZSTD_compressSequencesAndLiterals() should have failed: sequences define a block larger than ZSTD_BLOCKSIZE_MAX\n"); ZSTD_freeCCtx(cctx); goto _output_error; From e6dc2fbee02b3431ecf8cdc773a92d706e678588 Mon Sep 17 00:00:00 2001 From: Usama Arif Date: Mon, 7 Sep 2026 08:27:10 -0700 Subject: [PATCH 04/94] decompress: Remove stale Clang branch-hint comments The Clang-specific LIKELY and UNLIKELY annotations in ZSTD_decodeSequence() were removed because they hurt cache locality in PGO builds. The comments that justified those annotations remained and now appear in both architecture-specific implementations. Remove both stale comments now that they no longer describe the code. Signed-off-by: Usama Arif --- lib/decompress/zstd_decompress_block.c | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/decompress/zstd_decompress_block.c b/lib/decompress/zstd_decompress_block.c index 56e1f9eda28..f342fffe198 100644 --- a/lib/decompress/zstd_decompress_block.c +++ b/lib/decompress/zstd_decompress_block.c @@ -1280,10 +1280,6 @@ ZSTD_decodeSequence(seqState_t* seqState, const ZSTD_longOffset_e longOffsets, c assert(llBits <= MaxLLBits); assert(mlBits <= MaxMLBits); assert(ofBits <= MaxOff); - /* As GCC has better branch and block analyzers, sometimes it is only - * valuable to mark likeliness for Clang. - */ - /* sequence */ { size_t offset; if (ofBits > 1) { @@ -1370,10 +1366,6 @@ ZSTD_decodeSequence(seqState_t* seqState, const ZSTD_longOffset_e longOffsets, c assert(llBits <= MaxLLBits); assert(mlBits <= MaxMLBits); assert(ofBits <= MaxOff); - /* As GCC has better branch and block analyzers, sometimes it is only - * valuable to mark likeliness for Clang. - */ - /* sequence */ { size_t offset; if (ofBits > 1) { From 61da23ab46af55e80b8b4d5a03ec9451096b9c46 Mon Sep 17 00:00:00 2001 From: KSAGlory <178437733+KSAGlory@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:52:20 +0200 Subject: [PATCH 05/94] Enforce literal buffer capacity contract --- lib/compress/zstd_compress.c | 2 +- tests/fuzzer.c | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 50c3e6f7279..d1859812fd4 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -8125,7 +8125,7 @@ ZSTD_compressSequencesAndLiterals(ZSTD_CCtx* cctx, /* Transparent initialization stage, same as compressStream2() */ DEBUGLOG(4, "ZSTD_compressSequencesAndLiterals (dstCapacity=%zu)", dstCapacity); assert(cctx != NULL); - if (litCapacity < litSize) { + if (litCapacity < litSize || litCapacity - litSize < 8) { RETURN_ERROR(workSpace_tooSmall, "literals buffer is not large enough: must be at least 8 bytes larger than litSize (risk of read out-of-bound)"); } FORWARD_IF_ERROR(ZSTD_CCtx_init_compressStream2(cctx, ZSTD_e_end, decompressedSize), "CCtx initialization failed"); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 32360b9a58d..6702a68774b 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4476,6 +4476,13 @@ static int basicUnitTests(U32 const seed, double compressibility) { size_t const litSize = FUZ_getLitSize(seqs, nbSeqs); FUZ_transferLiterals(litBuffer, decompressSize, CNBuffer, srcSize, seqs, nbSeqs); + /* literal buffer capacity without 8 bytes of margin: must fail */ + compressedSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, seqs, nbSeqs, litBuffer, litSize, litSize+7, srcSize); + if (ZSTD_getErrorCode(compressedSize) != ZSTD_error_workSpace_tooSmall) { + DISPLAY("ZSTD_compressSequencesAndLiterals() should have failed: literals buffer capacity is too small\n"); + goto _output_error; + } + /* not enough literals: must fail */ compressedSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, seqs, nbSeqs, src, litSize-1, decompressSize, srcSize); if (!ZSTD_isError(compressedSize)) { @@ -4505,7 +4512,7 @@ static int basicUnitTests(U32 const seed, double compressibility) } /* correct amount of literals: should compress successfully */ - compressedSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, seqs, nbSeqs, litBuffer, litSize, decompressSize, srcSize); + compressedSize = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, seqs, nbSeqs, litBuffer, litSize, litSize+8, srcSize); if (ZSTD_isError(compressedSize)) { DISPLAY("Error in ZSTD_compressSequencesAndLiterals()\n"); goto _output_error; From 5641f822f1b071d4cef6250e674ddf15ee1276d4 Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Thu, 1 Oct 2026 19:42:23 +0530 Subject: [PATCH 06/94] cmake: fix CXX flag dependency in AddZstdCompilationFlags when C++ is not enabled Signed-off-by: Tyagiquamar --- .../AddZstdCompilationFlags.cmake | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/build/cmake/CMakeModules/AddZstdCompilationFlags.cmake b/build/cmake/CMakeModules/AddZstdCompilationFlags.cmake index 3e7bcce5974..68a086908b0 100644 --- a/build/cmake/CMakeModules/AddZstdCompilationFlags.cmake +++ b/build/cmake/CMakeModules/AddZstdCompilationFlags.cmake @@ -23,7 +23,7 @@ function(EnableCompilerFlag _flag _C _CXX _LD) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${_flag}" PARENT_SCOPE) endif () endif () - if (_CXX AND CMAKE_CXX_COMPILER) + if (_CXX AND CMAKE_CXX_COMPILER_LOADED) CHECK_CXX_COMPILER_FLAG(${_flag} CXX_FLAG_${varname}) if (CXX_FLAG_${varname}) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${_flag}" PARENT_SCOPE) @@ -56,42 +56,42 @@ macro(ADD_ZSTD_COMPILATION_FLAGS _C _CXX _LD) # compiler flags to mark the stack as non-executable. set(ZSTD_HAS_NOEXECSTACK false) - if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" OR MINGW) #Not only UNIX but also WIN32 for MinGW + if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" OR CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang" OR MINGW) #Not only UNIX but also WIN32 for MinGW # It's possible to select the exact standard used for compilation. # It's not necessary, but can be employed for specific purposes. # Note that zstd source code is compatible with both C++98 and above # and C-gnu90 (c90 + long long + variadic macros ) and above # EnableCompilerFlag("-std=c++11" false true) # Set C++ compilation to c++11 standard # EnableCompilerFlag("-std=c99" true false) # Set C compilation to c99 standard - if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND MSVC) + if ((CMAKE_C_COMPILER_ID MATCHES "Clang" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") AND MSVC) # clang-cl normally maps -Wall to -Weverything. - EnableCompilerFlag("/clang:-Wall" _C _CXX false) + EnableCompilerFlag("/clang:-Wall" ${_C} ${_CXX} false) else () - EnableCompilerFlag("-Wall" _C _CXX false) + EnableCompilerFlag("-Wall" ${_C} ${_CXX} false) endif () - EnableCompilerFlag("-Wextra" _C _CXX false) - EnableCompilerFlag("-Wundef" _C _CXX false) - EnableCompilerFlag("-Wshadow" _C _CXX false) - EnableCompilerFlag("-Wcast-align" _C _CXX false) - EnableCompilerFlag("-Wcast-qual" _C _CXX false) - EnableCompilerFlag("-Wstrict-prototypes" _C false false) + EnableCompilerFlag("-Wextra" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wundef" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wshadow" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wcast-align" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wcast-qual" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wstrict-prototypes" ${_C} false false) # Enable asserts in Debug mode if (CMAKE_BUILD_TYPE MATCHES "Debug") - EnableCompilerFlag("-DDEBUGLEVEL=1" _C _CXX false) + EnableCompilerFlag("-DDEBUGLEVEL=1" ${_C} ${_CXX} false) endif () # Add noexecstack flags # LDFLAGS - EnableCompilerFlag("-Wl,-z,noexecstack" false false _LD) + EnableCompilerFlag("-Wl,-z,noexecstack" false false ${_LD}) # CFLAGS & CXXFLAGS - EnableCompilerFlag("-Qunused-arguments" _C _CXX false) - EnableCompilerFlag("-Wa,--noexecstack" _C _CXX false) + EnableCompilerFlag("-Qunused-arguments" ${_C} ${_CXX} false) + EnableCompilerFlag("-Wa,--noexecstack" ${_C} ${_CXX} false) # NOTE: Using 3 nested ifs because the variables are sometimes # empty if the condition is false, and sometimes equal to false. # This implicitly converts them to truthy values. There may be # a better way to do this, but this reliably works. - if (${LD_FLAG_WL_Z_NOEXECSTACK}) - if (${C_FLAG_WA_NOEXECSTACK}) - if (${CXX_FLAG_WA_NOEXECSTACK}) + if (LD_FLAG_WL_Z_NOEXECSTACK) + if (C_FLAG_WA_NOEXECSTACK) + if (NOT CMAKE_CXX_COMPILER_LOADED OR CXX_FLAG_WA_NOEXECSTACK) # We've succeeded in marking the stack as non-executable set(ZSTD_HAS_NOEXECSTACK true) endif() From 81c441487277ac73405184e85d5acb83018190dc Mon Sep 17 00:00:00 2001 From: Filippo Tedeschi Date: Mon, 5 Oct 2026 15:08:02 +0200 Subject: [PATCH 07/94] decompress: drop the DDict hashset on ZSTD_DCtx_reset and ZSTD_DCtx_refDDict(NULL) When ZSTD_d_refMultipleDDicts is used, the DCtx keeps a hashset of DDict pointers. ZSTD_DCtx_reset(session_and_parameters) and ZSTD_DCtx_refDDict(dctx, NULL) cleared the dictionary but left the set populated, so later references were added to a set that still held pointers to DDicts the caller may have freed. A frame asking for the dictID of one of those then made ZSTD_DCtx_selectFrameDDict() read the freed DDict. Free the set in both places, and add a test that decodes a frame after the referenced DDict has been freed. --- lib/decompress/zstd_decompress.c | 7 +++ tests/fuzzer.c | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 325f0b54dfc..300197f1db7 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -1793,6 +1793,9 @@ size_t ZSTD_DCtx_refDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) assert(!dctx->staticSize); /* Impossible: ddictSet cannot have been allocated if static dctx */ FORWARD_IF_ERROR(ZSTD_DDictHashSet_addDDict(dctx->ddictSet, ddict, dctx->customMem), ""); } + } else if (dctx->ddictSet) { + ZSTD_freeDDictHashSet(dctx->ddictSet, dctx->customMem); + dctx->ddictSet = NULL; } return 0; } @@ -1955,6 +1958,10 @@ size_t ZSTD_DCtx_reset(ZSTD_DCtx* dctx, ZSTD_ResetDirective reset) || (reset == ZSTD_reset_session_and_parameters) ) { RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); + if (dctx->ddictSet) { + ZSTD_freeDDictHashSet(dctx->ddictSet, dctx->customMem); + dctx->ddictSet = NULL; + } ZSTD_DCtx_resetParameters(dctx); } return 0; diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 32360b9a58d..8978bcc5ec2 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3941,6 +3941,80 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK (ASAN would report OOB write if vulnerable)\n"); + DISPLAYLEVEL(3, "test%3i : DDict hashset is dropped by reset and refDDict(NULL) : ", testNb++); + { + /* After ZSTD_reset_session_and_parameters, the DCtx must not keep + * pointers to DDicts the caller has since freed. */ + const char msg[] = "reset the ddict hashset, reset the ddict hashset"; + char frame[256]; + char out[256]; + size_t frameSize; + size_t r; + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + ZSTD_CCtx* const cctx2 = ZSTD_createCCtx(); + char* const dictBufA = (char*)malloc(dictBufferFixedSize); + char* const dictBufB = (char*)malloc(dictBufferFixedSize); + ZSTD_CDict* cdictA = NULL; + ZSTD_DDict* ddictA = NULL; + ZSTD_DDict* ddictB = NULL; + + if (!dctx || !cctx2 || !dictBufA || !dictBufB) { + DISPLAY("alloc failed\n"); + goto _output_error; + } + ZSTD_memcpy(dictBufA, dictBufferFixed, dictBufferFixedSize); + ZSTD_memcpy(dictBufB, dictBufferFixed, dictBufferFixedSize); + MEM_writeLE32(dictBufA + ZSTD_FRAMEIDSIZE, 101); + MEM_writeLE32(dictBufB + ZSTD_FRAMEIDSIZE, 102); + cdictA = ZSTD_createCDict(dictBufA, dictBufferFixedSize, 1); + ddictA = ZSTD_createDDict(dictBufA, dictBufferFixedSize); + ddictB = ZSTD_createDDict(dictBufB, dictBufferFixedSize); + if (!cdictA || !ddictA || !ddictB) { + DISPLAY("dictionary creation failed\n"); + goto _output_error; + } + frameSize = ZSTD_compress_usingCDict(cctx2, frame, sizeof(frame), msg, sizeof(msg), cdictA); + if (ZSTD_isError(frameSize)) goto _output_error; + + CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); + CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictA) ); + CHECK_Z( ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters) ); + ZSTD_freeDDict(ddictA); + ddictA = NULL; + + /* Only ddictB is referenced now: a frame that asks for dictID 101 + * must be rejected, and must not look at the freed ddictA. */ + CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); + CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictB) ); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, frameSize); + if (!ZSTD_isError(r)) { + DISPLAY("frame decoded with a dictionary that was reset away\n"); + goto _output_error; + } + + /* Same thing for ZSTD_DCtx_refDDict(dctx, NULL). */ + { ZSTD_DDict* const ddictA2 = ZSTD_createDDict(dictBufA, dictBufferFixedSize); + if (!ddictA2) goto _output_error; + CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictA2) ); + CHECK_Z( ZSTD_DCtx_refDDict(dctx, NULL) ); + ZSTD_freeDDict(ddictA2); + CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictB) ); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, frameSize); + if (!ZSTD_isError(r)) { + DISPLAY("frame decoded with a dictionary that was unreferenced\n"); + goto _output_error; + } + } + + ZSTD_freeDDict(ddictB); + ZSTD_freeCDict(cdictA); + ZSTD_freeCCtx(cctx2); + ZSTD_freeDCtx(dctx); + free(dictBufA); + free(dictBufB); + } + DISPLAYLEVEL(3, "OK \n"); + ZSTD_freeCCtx(cctx); free(dictBuffer); free(samplesSizes); From e47dfb3f1b0863770dbe7defbcdfd4dcec2dff82 Mon Sep 17 00:00:00 2001 From: Ilyas Mallah Date: Fri, 9 Oct 2026 02:36:02 +0200 Subject: [PATCH 08/94] programs: fix zstd -l infinite loop on a large skippable frame --- programs/fileio.c | 2 +- tests/playTests.sh | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/programs/fileio.c b/programs/fileio.c index 78a906d533b..80c49aecea0 100644 --- a/programs/fileio.c +++ b/programs/fileio.c @@ -3515,7 +3515,7 @@ FIO_analyzeFrames(fileInfo_t* info, FILE* const srcFile) /* Skippable frame */ else if ((magicNumber & ZSTD_MAGIC_SKIPPABLE_MASK) == ZSTD_MAGIC_SKIPPABLE_START) { U32 const frameSize = MEM_readLE32(headerBuffer + 4); - long const seek = (long)(8 + frameSize - numBytesRead); + S64 const seek = (S64)ZSTD_SKIPPABLEHEADERSIZE + frameSize - (S64)numBytesRead; ERROR_IF(LONG_SEEK(srcFile, seek, SEEK_CUR) != 0, info_frame_error, "Error: could not find end of skippable frame"); info->numSkippableFrames++; diff --git a/tests/playTests.sh b/tests/playTests.sh index 44bc82b5610..b08424a8d97 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -1637,6 +1637,11 @@ rm -f $TEST_DATA_FILE rm -f $FULL_COMPRESSED_FILE rm -f $TRUNCATED_COMPRESSED_FILE +println "test : detect skippable frame larger than file " +printf '\120\052\115\030\370\377\377\377payload' > tmp_skippable.zst # skippable magic, Frame_Size 0xFFFFFFF8 +zstd --list tmp_skippable.zst && die "-l must fail on truncated skippable frame" +rm -f tmp_skippable.zst + println "\n===> zstd --list/-l errors when presented with stdin / no files" zstd -l && die "-l must fail on empty list of files" zstd -l - && die "-l does not work on stdin" From ed26f948cd553ed4f35e681c06bb03be3426f2b8 Mon Sep 17 00:00:00 2001 From: Eshan05 Date: Wed, 28 Oct 2026 21:41:14 +0530 Subject: [PATCH 09/94] compress: wait for an abandoned frame's jobs before freeing what they use With nbWorkers >= 1, a job of an abandoned frame can still be running when the context frees memory it uses: - ZSTD_freeCCtx() frees the dictionary before ZSTDMT_freeCCtx() joins the workers; - ZSTD_CCtx_reset(session_and_parameters), or a session reset followed by loading another dictionary, frees it while the job runs; - with ZSTD_CCtx_refThreadPool(), ZSTDMT_freeCCtx() does not stop the shared pool and frees the jobs table and CCtx pool under the worker; - after a session reset, raising nbWorkers resizes the CCtx pool before the abandoned jobs are waited for. Move the "previous compression not correctly finished" wait of ZSTDMT_initCStream_internal() into ZSTDMT_waitForPendingJobs(), and call it on session reset, at the start of ZSTDMT_freeCCtx(), and before ZSTDMT_resize(). Free the MT context before the dictionaries. Fixes #4862 Signed-off-by: Eshan05 --- lib/compress/zstd_compress.c | 8 ++++-- lib/compress/zstdmt_compress.c | 15 +++++++---- lib/compress/zstdmt_compress.h | 6 +++++ lib/zstd.h | 1 + tests/zstreamtest.c | 47 ++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 38c3fc0d4cd..88e1df51232 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -184,10 +184,10 @@ static void ZSTD_freeCCtxContent(ZSTD_CCtx* cctx) { assert(cctx != NULL); assert(cctx->staticSize == 0); - ZSTD_clearAllDicts(cctx); #ifdef ZSTD_MULTITHREAD - ZSTDMT_freeCCtx(cctx->mtctx); cctx->mtctx = NULL; + ZSTDMT_freeCCtx(cctx->mtctx); cctx->mtctx = NULL; /* pending jobs may use the dictionary */ #endif + ZSTD_clearAllDicts(cctx); ZSTD_cwksp_free(&cctx->workspace, cctx->customMem); } @@ -1389,6 +1389,10 @@ size_t ZSTD_CCtx_reset(ZSTD_CCtx* cctx, ZSTD_ResetDirective reset) { if ( (reset == ZSTD_reset_session_only) || (reset == ZSTD_reset_session_and_parameters) ) { +#ifdef ZSTD_MULTITHREAD + /* jobs of an abandoned frame may still reference the dictionary */ + if (cctx->mtctx != NULL) ZSTDMT_waitForPendingJobs(cctx->mtctx); +#endif cctx->streamStage = zcss_init; cctx->pledgedSrcSizePlusOne = 0; } diff --git a/lib/compress/zstdmt_compress.c b/lib/compress/zstdmt_compress.c index cb710475c2a..1594221c4e9 100644 --- a/lib/compress/zstdmt_compress.c +++ b/lib/compress/zstdmt_compress.c @@ -1043,9 +1043,18 @@ static void ZSTDMT_waitForAllJobsCompleted(ZSTDMT_CCtx* mtctx) } } +void ZSTDMT_waitForPendingJobs(ZSTDMT_CCtx* mtctx) +{ + if (mtctx->allJobsCompleted == 0) { /* previous compression not correctly finished */ + ZSTDMT_waitForAllJobsCompleted(mtctx); + ZSTDMT_releaseAllJobResources(mtctx); /* Will set allJobsCompleted to 1 */ + } +} + size_t ZSTDMT_freeCCtx(ZSTDMT_CCtx* mtctx) { if (mtctx==NULL) return 0; /* compatible with free on NULL */ + ZSTDMT_waitForPendingJobs(mtctx); /* a shared pool is not stopped below */ if (!mtctx->providedFactory) POOL_free(mtctx->factory); /* stop and free worker threads */ ZSTDMT_releaseAllJobResources(mtctx); /* release job resources into pools first */ @@ -1262,17 +1271,13 @@ size_t ZSTDMT_initCStream_internal( assert(!((dict) && (cdict))); /* either dict or cdict, not both */ /* init */ + ZSTDMT_waitForPendingJobs(mtctx); /* before resizing the pools they use */ if (params.nbWorkers != mtctx->params.nbWorkers) FORWARD_IF_ERROR( ZSTDMT_resize(mtctx, (unsigned)params.nbWorkers) , ""); if (params.jobSize != 0 && params.jobSize < ZSTDMT_JOBSIZE_MIN) params.jobSize = ZSTDMT_JOBSIZE_MIN; if (params.jobSize > (size_t)ZSTDMT_JOBSIZE_MAX) params.jobSize = (size_t)ZSTDMT_JOBSIZE_MAX; - if (mtctx->allJobsCompleted == 0) { /* previous compression not correctly finished */ - ZSTDMT_waitForAllJobsCompleted(mtctx); - ZSTDMT_releaseAllJobResources(mtctx); /* Will set allJobsCompleted to 1 */ - } - mtctx->params = params; mtctx->frameContentSize = pledgedSrcSize; ZSTD_freeCDict(mtctx->cdictLocal); diff --git a/lib/compress/zstdmt_compress.h b/lib/compress/zstdmt_compress.h index 91b489b9cb4..3e2ee664ed0 100644 --- a/lib/compress/zstdmt_compress.h +++ b/lib/compress/zstdmt_compress.h @@ -49,6 +49,12 @@ ZSTDMT_CCtx* ZSTDMT_createCCtx_advanced(unsigned nbWorkers, ZSTD_threadPool *pool); size_t ZSTDMT_freeCCtx(ZSTDMT_CCtx* mtctx); +/*! ZSTDMT_waitForPendingJobs() : + * Waits for the jobs of an unfinished frame to complete, then releases their resources. + * Must be called before freeing anything these jobs reference, such as the dictionary. + * Does nothing if the last frame was completed. */ +void ZSTDMT_waitForPendingJobs(ZSTDMT_CCtx* mtctx); + size_t ZSTDMT_sizeof_CCtx(ZSTDMT_CCtx* mtctx); /* === Streaming functions === */ diff --git a/lib/zstd.h b/lib/zstd.h index 97fef316fd6..3c24acc230d 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -597,6 +597,7 @@ typedef enum { * - The session : will stop compressing current frame, and make CCtx ready to start a new one. * Useful after an error, or to interrupt any ongoing compression. * Any internal data not yet flushed is cancelled. + * With nbWorkers >= 1, it waits for the jobs already started to complete. * Compression parameters and dictionary remain unchanged. * They will be used to compress next frame. * Resetting session never fails. diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index 88a20a3e270..22b038828cd 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -2512,6 +2512,53 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) } DISPLAYLEVEL(3, "OK \n"); +#ifdef ZSTD_MULTITHREAD + /* Freeing or resetting the context must wait for the jobs still running, + * since they use its dictionary, pools and jobs table */ + DISPLAYLEVEL(3, "test%3i : abandon a multi-threaded frame with a job in progress : ", testNb++); + { size_t const jobSize = 1 MB; + size_t const srcSize = jobSize + 64 KB; /* starts one job, the rest stays buffered */ + size_t const largeDictSize = 1 MB; + const void* const largeDict = (const char*)CNBuffer + srcSize; + int scenario; + for (scenario = 0; scenario < 4; scenario++) { + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + ZSTD_threadPool* const pool = (scenario == 1) ? ZSTD_createThreadPool(1) : NULL; + ZSTD_inBuffer in = { CNBuffer, srcSize, 0 }; + ZSTD_outBuffer out = { compressedBuffer, compressedBufferSize, 0 }; + size_t nextDictSize = largeDictSize; + CHECK(cctx == NULL, "ZSTD_createCCtx failed"); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 10) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 1) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_jobSize, (int)jobSize) ); + if (pool != NULL) CHECK_Z( ZSTD_CCtx_refThreadPool(cctx, pool) ); + CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, largeDictSize) ); + CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); + CHECK(in.pos < jobSize, "the first job should be in progress"); + if (scenario >= 2) { + /* abandon the frame, then change the dictionary or the number of workers */ + CHECK_Z( ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only) ); + if (scenario == 2) { + nextDictSize = largeDictSize / 2; + CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, nextDictSize) ); + } else { + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 4) ); + } + cSize = ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, srcSize); + CHECK_Z(cSize); + { size_t const dSize = ZSTD_decompress_usingDict(zd, decodedBuffer, decodedBufferSize, + compressedBuffer, cSize, largeDict, nextDictSize); + CHECK_Z(dSize); + CHECK(dSize != srcSize, "wrong decompressed size"); + CHECK(memcmp(decodedBuffer, CNBuffer, srcSize), "decompressed data must match"); + } } + ZSTD_freeCCtx(cctx); /* scenarios 0 and 1 free it with the job in progress */ + ZSTD_freeThreadPool(pool); + } + } + DISPLAYLEVEL(3, "OK \n"); +#endif + _end: FUZ_freeDictionary(dictionary); ZSTD_freeCStream(zc); From c70f728c779308165ce77184d794642cafec1c68 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Fri, 6 Nov 2026 21:12:52 -0800 Subject: [PATCH 10/94] ci: run the macOS ARM64 CMake job on macos-latest The macos-14 runner image is no longer available: the job was cancelled before starting, with "The configuration 'macos-14-arm64-us-default' is not supported". macos-latest is an ARM64 (Apple Silicon) runner. --- .github/workflows/cmake-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cmake-tests.yml b/.github/workflows/cmake-tests.yml index 4af2463569c..2bb9a22d713 100644 --- a/.github/workflows/cmake-tests.yml +++ b/.github/workflows/cmake-tests.yml @@ -154,7 +154,7 @@ jobs: # Uses native ARM64 hardware for optimal performance and compatibility testing cmake-macos-arm64: name: "CMake macOS ARM64 (Apple Silicon)" - runs-on: macos-14 # ARM64 runner + runs-on: macos-latest # ARM64 runner steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: "CMake build and test (ARM64)" From b276bea2406abe87efb574046a074726b5f28341 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Fri, 6 Nov 2026 20:01:37 -0800 Subject: [PATCH 11/94] programs: apply --memory to .xz and .lzma decompression --memory (128 MiB by default) only limited the decompression of zstd frames: liblzma was initialized without any limit. Pass it to liblzma as well. When a file needs more, report how much memory it needs and suggest a --memory= value, as for zstd frames. .xz and .lzma files that need more than 128 MiB to decode now require --memory=, like zstd frames with a large window. --- programs/fileio.c | 19 +++++++++++++++---- programs/zstd.1.md | 2 +- tests/playTests.sh | 8 ++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/programs/fileio.c b/programs/fileio.c index 78a906d533b..dc8c6b7c53a 100644 --- a/programs/fileio.c +++ b/programs/fileio.c @@ -2829,7 +2829,7 @@ FIO_decompressGzFrame(dRess_t* ress, const char* srcFileName) #ifdef ZSTD_LZMADECOMPRESS static unsigned long long -FIO_decompressLzmaFrame(dRess_t* ress, +FIO_decompressLzmaFrame(dRess_t* ress, const FIO_prefs_t* const prefs, const char* srcFileName, int plain_lzma) { unsigned long long outFileSize = 0; @@ -2841,10 +2841,12 @@ FIO_decompressLzmaFrame(dRess_t* ress, strm.next_in = 0; strm.avail_in = 0; + /* --memory also limits xz/lzma decoding : liblzma refuses to allocate more */ + assert(prefs->memLimit > 0); if (plain_lzma) { - initRet = lzma_alone_decoder(&strm, UINT64_MAX); /* LZMA */ + initRet = lzma_alone_decoder(&strm, prefs->memLimit); /* LZMA */ } else { - initRet = lzma_stream_decoder(&strm, UINT64_MAX, 0); /* XZ */ + initRet = lzma_stream_decoder(&strm, prefs->memLimit, 0); /* XZ */ } if (initRet != LZMA_OK) { @@ -2874,6 +2876,15 @@ FIO_decompressLzmaFrame(dRess_t* ress, DISPLAYLEVEL(1, "zstd: %s: premature lzma end \n", srcFileName); decodingError = 1; break; } + if (ret == LZMA_MEMLIMIT_ERROR) { + U64 const memNeeded = lzma_memusage(&strm); + U64 const memMB = (memNeeded >> 20) + ((memNeeded & ((1 MB) - 1)) != 0); + DISPLAYLEVEL(1, "%s : Memory needed by xz/lzma decoder larger than maximum : %llu > %u \n", + srcFileName, (unsigned long long)memNeeded, prefs->memLimit); + if (memMB <= (UINT_MAX >> 20)) + DISPLAYLEVEL(1, "%s : Use --memory=%uMB \n", srcFileName, (unsigned)memMB); + decodingError = 1; break; + } if (ret != LZMA_OK && ret != LZMA_STREAM_END) { DISPLAYLEVEL(1, "zstd: %s: lzma_code decoding error %d \n", srcFileName, ret); @@ -3031,7 +3042,7 @@ static int FIO_decompressFrames(FIO_ctx_t* const fCtx, } else if ((buf[0] == 0xFD && buf[1] == 0x37) /* xz magic number */ || (buf[0] == 0x5D && buf[1] == 0x00)) { /* lzma header (no magic number) */ #ifdef ZSTD_LZMADECOMPRESS - unsigned long long const frameSize = FIO_decompressLzmaFrame(&ress, srcFileName, buf[0] != 0xFD); + unsigned long long const frameSize = FIO_decompressLzmaFrame(&ress, prefs, srcFileName, buf[0] != 0xFD); if (frameSize == FIO_ERROR_FRAME_DECODING) return 1; filesize += frameSize; #else diff --git a/programs/zstd.1.md b/programs/zstd.1.md index 1721441dfbb..74cd039544a 100644 --- a/programs/zstd.1.md +++ b/programs/zstd.1.md @@ -210,7 +210,7 @@ the last one takes effect. Set a memory usage limit. By default, `zstd` uses 128 MiB for decompression as the maximum amount of memory the decompressor is allowed to use, but you can override this manually if need be in either direction (i.e. you can increase or - decrease it). + decrease it). This limit also applies when decompressing `.xz` and `.lzma` files. This is also used during compression when using with `--patch-from=`. In this case, this parameter overrides that maximum size allowed for a dictionary. (128 MiB). diff --git a/tests/playTests.sh b/tests/playTests.sh index 44bc82b5610..5c180f5d4bf 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -1423,6 +1423,14 @@ if [ $LZMAMODE -eq 1 ]; then cat tmp.xz tmp.lzma tmp.zst tmp.lzma tmp.xz tmp.zst | zstd -d -f -o tmp truncateLastByte tmp.xz | zstd -t > $INTOVOID && die "incomplete frame not detected !" truncateLastByte tmp.lzma | zstd -t > $INTOVOID && die "incomplete frame not detected !" + # --memory also limits xz/lzma decoding (level 3 : 4 MiB dictionary, needs 5 MB) + zstd -f -3 --format=xz tmp -o tmp3.xz + zstd -f -3 --format=lzma tmp -o tmp3.lzma + zstd -t --memory=1MB tmp3.xz 2>tmplog && die "--memory not enforced for xz" + $GREP "Use --memory=5MB" tmplog + zstd -t --memory=1MB tmp3.lzma 2>tmplog && die "--memory not enforced for lzma" + $GREP "Use --memory=5MB" tmplog + zstd -t --memory=5MB tmp3.xz tmp3.lzma rm -f tmp* else println "xz mode not supported" From 6bdc1d32b4037628ba94ed1ac72ee508734b1720 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Sat, 7 Nov 2026 20:45:55 -0800 Subject: [PATCH 12/94] programs: don't follow symbolic links and junctions in -r on Windows The CLI does not follow symbolic links by default (only -f does), but on Windows UTIL_isLink() always returned 0, and the recursive directory walk entered symbolic links and junctions like ordinary directories. -r could then compress, and with --rm delete, files outside the given directory. Treat name surrogate reparse points, i.e. symbolic links and junctions, as links, like lstat() does on POSIX. Other reparse points, such as OneDrive placeholders or deduplicated files, remain regular files and directories. Fixes #4853. The test comes from #4860. Co-authored-by: Nmd-sketch --- programs/util.c | 34 ++++++++++++++++++++++++++++++++++ tests/playTests.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/programs/util.c b/programs/util.c index 652530b1223..edd858ba723 100644 --- a/programs/util.c +++ b/programs/util.c @@ -490,6 +490,17 @@ int UTIL_isBlockDevStat(const stat_t* statbuf) return 0; } +#if defined(_WIN32) +/* Symbolic links and junctions are "name surrogate" reparse points. + * Other reparse points (OneDrive placeholders, deduplicated files, ...) + * are regular files and directories : they must not be treated as links. */ +static int UTIL_isNameSurrogate(DWORD attributes, DWORD reparseTag) +{ + return (attributes & FILE_ATTRIBUTE_REPARSE_POINT) + && IsReparseTagNameSurrogate(reparseTag); +} +#endif + int UTIL_isLink(const char* infilename) { UTIL_TRACE_CALL("UTIL_isLink(%s)", infilename); @@ -503,6 +514,24 @@ int UTIL_isLink(const char* infilename) return 1; } } +#elif defined(_WIN32) + { + DWORD const attributes = GetFileAttributesA(infilename); + if ( attributes != INVALID_FILE_ATTRIBUTES + && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) ) { + /* GetFileAttributes() does not report the reparse tag, FindFirstFile() does */ + WIN32_FIND_DATAA findData; + HANDLE const hFind = FindFirstFileA(infilename, &findData); + if (hFind != INVALID_HANDLE_VALUE) { + int const isLink = UTIL_isNameSurrogate(findData.dwFileAttributes, findData.dwReserved0); + FindClose(hFind); + if (isLink) { + UTIL_TRACE_RET(1); + return 1; + } + } + } + } #endif (void)infilename; UTIL_TRACE_RET(0); @@ -942,6 +971,11 @@ static int UTIL_prepareFileList(const char* dirName, memcpy(path+dirLength+1, cFile.cFileName, fnameLength); pathLength = dirLength+1+fnameLength; path[pathLength] = 0; + if (!followLinks && UTIL_isNameSurrogate(cFile.dwFileAttributes, cFile.dwReserved0)) { + UTIL_DISPLAYLEVEL(2, "Warning : %s is a symbolic link, ignoring\n", path); + free(path); + continue; + } if (cFile.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { if ( strcmp (cFile.cFileName, "..") == 0 || strcmp (cFile.cFileName, ".") == 0 ) diff --git a/tests/playTests.sh b/tests/playTests.sh index fd20d59c25f..f1c26c8d9a7 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -443,6 +443,34 @@ mkdir -p tmpEmptyDir zstd -r tmpEmptyDir rm -rf tmpEmptyDir +println "test : directory links are not followed by -r" +# remove leftovers of a previous failed run +if [ "$isWindows" = true ]; then cmd //c "rmdir tmpLinkDir\tmpLink" 2> /dev/null || :; fi +rm -rf tmpLinkDir tmpOutside tmpStdErr +mkdir -p tmpLinkDir tmpOutside +echo "inner" > tmpLinkDir/tmpInner +echo "victim" > tmpOutside/tmpVictim +if [ "$isWindows" = true ]; then + # junctions don't require the privileges that symlinks do + (cd tmpLinkDir && cmd //c "mklink /J tmpLink ..\tmpOutside") +else + ln -s ../tmpOutside tmpLinkDir/tmpLink +fi +zstd -r --rm tmpLinkDir 2> tmpStdErr +# remove the link before asserting, so a failing test never leaves a live +# link behind for the later "rm -rf tmp*" cleanups to encounter +if [ "$isWindows" = true ]; then + cmd //c "rmdir tmpLinkDir\tmpLink" +else + rm -f tmpLinkDir/tmpLink +fi +$GREP "symbolic link" tmpStdErr +test -f tmpLinkDir/tmpInner.zst # file inside the tree was compressed +test ! -f tmpLinkDir/tmpInner # ... and removed (--rm) +test -f tmpOutside/tmpVictim # file outside the tree was left untouched +test ! -f tmpOutside/tmpVictim.zst # ... not compressed through the link +rm -rf tmpLinkDir tmpOutside tmpStdErr + println "\n===> file removal" zstd -f --rm tmp From f9c9c683823ffc6fa6c773b7c015a855884baa99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Fri, 6 Nov 2026 13:54:32 +0300 Subject: [PATCH 13/94] seekable_format: Reject offsets past the end in decompress ZSTD_seekable_decompress() limited len with offset + len > eos. An offset well past the end made eos - offset underflow into a huge byte count that ZSTD_isError() did not flag. An offset + len that wrapped around skipped the limit, and the loop never exited. Return parameter_outOfBound for an offset past the end, and compare len against eos - offset. Reading at the end still returns 0. --- .../seekable_format/tests/seekable_tests.c | 56 +++++++++++++++++++ contrib/seekable_format/zstdseek_decompress.c | 5 +- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index b535f5571ef..5fd9a51a80c 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -388,6 +388,62 @@ int main(int argc, const char** argv) } printf("Success!\n"); + printf("Test %u - decompress at and past the end of the data: ", testNb++); + { char const inBuffer[] = "Lorem ipsum dolor sit amet"; + size_t const inSize = sizeof(inBuffer); + + size_t const seekCapacity = 5000; + void* const seekBuffer = malloc(seekCapacity); + assert(seekBuffer != NULL); + size_t seekSize; + + char outBuffer[16]; + + ZSTD_seekable_CStream* const zscs = ZSTD_seekable_createCStream(); + assert(zscs != NULL); + { size_t const initStatus = ZSTD_seekable_initCStream(zscs, 9, 0 /* checksumFlag */, 0 /* maxFrameSize */); + assert(!ZSTD_isError(initStatus)); + } + + { ZSTD_outBuffer outb = { .dst=seekBuffer, .pos=0, .size=seekCapacity }; + ZSTD_inBuffer inb = { .src=inBuffer, .pos=0, .size=inSize }; + + size_t const cStatus = ZSTD_seekable_compressStream(zscs, &outb, &inb); + assert(!ZSTD_isError(cStatus)); + assert(inb.pos == inb.size); + + size_t const endStatus = ZSTD_seekable_endStream(zscs, &outb); + assert(!ZSTD_isError(endStatus)); + seekSize = outb.pos; + } + + ZSTD_seekable* const stream = ZSTD_seekable_create(); + assert(stream != NULL); + { size_t const initStatus = ZSTD_seekable_initBuff(stream, seekBuffer, seekSize); + assert(!ZSTD_isError(initStatus)); } + + { size_t const atEnd = ZSTD_seekable_decompress(stream, outBuffer, sizeof(outBuffer), inSize); + size_t const pastEnd = ZSTD_seekable_decompress(stream, outBuffer, sizeof(outBuffer), inSize + 5000); + int failed = atEnd != 0 || !ZSTD_isError(pastEnd); + if (!failed) { + /* offset + len wraps around to 0 */ + size_t const wrapped = ZSTD_seekable_decompress(stream, outBuffer, sizeof(outBuffer), 0ULL - sizeof(outBuffer)); + failed = !ZSTD_isError(wrapped); + } + if (failed) { + free(seekBuffer); + ZSTD_seekable_freeCStream(zscs); + ZSTD_seekable_free(stream); + goto _test_error; + } + } + + free(seekBuffer); + ZSTD_seekable_freeCStream(zscs); + ZSTD_seekable_free(stream); + } + printf("Success!\n"); + /* TODO: Add more tests */ printf("Finished tests\n"); return 0; diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index dc66c35f7f9..352fbb5e863 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -496,8 +496,9 @@ size_t ZSTD_seekable_initAdvanced(ZSTD_seekable* zs, ZSTD_seekable_customFile sr size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsigned long long offset) { unsigned long long const eos = zs->seekTable.entries[zs->seekTable.tableLen].dOffset; - if (offset + len > eos) { - len = eos - offset; + if (offset > eos) return ERROR(parameter_outOfBound); + if (len > eos - offset) { + len = (size_t)(eos - offset); } U32 targetFrame = ZSTD_seekable_offsetToFrameIndex(zs, offset); From 75aeac489ae35d787dc6ba899f302d3f8dfe3f49 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 5 Nov 2026 14:11:45 -0800 Subject: [PATCH 14/94] decompress: reject output overrunning split literals and oversized blocks When a block's literals are split between dst and litExtraBuffer, the first part of the literals is stored at the end of the block's area in dst. A block that decodes to more than that area could overwrite these literals before reading them, and decode to wrong output: - ZSTD_safecopyDstBeforeSrc() used ZSTD_wildcopy() when src was more than 16 bytes ahead of dst, but ZSTD_wildcopy() can write up to 31 bytes past its length, onto source bytes not yet copied. Only use it when src is at least WILDCOPY_OVERLENGTH bytes ahead. - ZSTD_execSequenceEndSplitLitBuffer() only detected op starting inside the literals being copied. Reject any sequence whose output runs past the end of the literals it consumes, which also covers matches overwriting the literals of the next sequences. - ZSTD_decompressFrame() didn't check that a block decodes to at most blockSizeMax, unlike ZSTD_decompressContinue(). One-shot decompression accepted such blocks, including raw and RLE blocks larger than ZSTD_d_maxBlockSize. Valid blocks never decode to more than MIN(blockSizeMax, dstCapacity), so the output stays at least 64 KB - 32 bytes behind the literals in dst, and isn't affected. All writes were within dst, so this was not a memory safety issue. Frames with blocks larger than the window are now rejected by the one-shot API, as they already were by streaming. Add tests with a crafted block for the block and frame APIs, and for ZSTD_d_maxBlockSize with raw and RLE blocks. --- lib/decompress/zstd_decompress.c | 1 + lib/decompress/zstd_decompress_block.c | 10 +- tests/fuzzer.c | 155 ++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 325f0b54dfc..dcd911bcf36 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -1028,6 +1028,7 @@ static size_t ZSTD_decompressFrame(ZSTD_DCtx* dctx, RETURN_ERROR(corruption_detected, "invalid block type"); } FORWARD_IF_ERROR(decodedSize, "Block decompression failure"); + RETURN_ERROR_IF(decodedSize > dctx->fParams.blockSizeMax, corruption_detected, "Decompressed Block Size Exceeds Maximum"); DEBUGLOG(5, "Decompressed block of dSize = %u", (unsigned)decodedSize); if (dctx->validateChecksum) { XXH64_update(&dctx->xxhState, op, decodedSize); diff --git a/lib/decompress/zstd_decompress_block.c b/lib/decompress/zstd_decompress_block.c index 8d108c72ece..a3df55e0f43 100644 --- a/lib/decompress/zstd_decompress_block.c +++ b/lib/decompress/zstd_decompress_block.c @@ -892,7 +892,10 @@ static void ZSTD_safecopyDstBeforeSrc(BYTE* op, const BYTE* ip, size_t length) return; } - if (op <= oend - WILDCOPY_OVERLENGTH && diff < -WILDCOPY_VECLEN) { + /* ZSTD_wildcopy() may write up to WILDCOPY_OVERLENGTH-1 bytes past its length. + * Those bytes must not land on source bytes the leftover loop has yet to read, + * so src must be at least WILDCOPY_OVERLENGTH bytes ahead of dst. */ + if (op <= oend - WILDCOPY_OVERLENGTH && diff <= -WILDCOPY_OVERLENGTH) { ZSTD_wildcopy(op, ip, (size_t)(oend - WILDCOPY_OVERLENGTH - op), ZSTD_no_overlap); ip += oend - WILDCOPY_OVERLENGTH - op; op += oend - WILDCOPY_OVERLENGTH - op; @@ -977,8 +980,11 @@ size_t ZSTD_execSequenceEndSplitLitBuffer(BYTE* op, assert(op < op + sequenceLength); assert(oLitEnd < op + sequenceLength); + /* The literals still to be read live in dst at [*litPtr, litLimit). + * The output of this sequence must not run past the literals it consumes. */ + RETURN_ERROR_IF(op + sequenceLength > iLitEnd, dstSize_tooSmall, "output should not catch up to and overwrite literal buffer"); + /* copy literals */ - RETURN_ERROR_IF(op > *litPtr && op < *litPtr + sequence.litLength, dstSize_tooSmall, "output should not catch up to and overwrite literal buffer"); ZSTD_safecopyDstBeforeSrc(op, *litPtr, sequence.litLength); op = oLitEnd; *litPtr = iLitEnd; diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 32360b9a58d..85063ef29c4 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -575,6 +575,126 @@ static void test_decompressBound(unsigned tnb) DISPLAYLEVEL(3, "OK \n"); } +/* Size of the literals of the block built by FUZ_writeSplitLitBlock(). + * It is larger than the decoder's 64 KB extra literal buffer, so the first + * kSplitLitInDst literals are stored at the end of the block's area in dst. */ +#define kSplitLitSize (64 KB + 112) +#define kSplitLitInDst (kSplitLitSize - 64 KB) +#define kSplitLitBlockMaxSize (kSplitLitSize + 3 + 9) + +/* Writes a compressed block (without block header) with raw literals and 2 sequences, + * which decodes to 48 literals, a match of 65539+seq1MLExtra bytes at offset 1, + * 49 literals, a match of 3 bytes at offset 1, and the remaining literals. + * The block decodes to more than ZSTD_BLOCKSIZE_MAX bytes, so the first match runs + * up to (or past) the literals that are stored in dst. + * Writes the expected output to expected, which must hold + * FUZ_splitLitBlockDecodedSize(seq1MLExtra) bytes. + * @return : size of the block */ +static size_t FUZ_writeSplitLitBlock(BYTE* block, BYTE* expected, U32 seq1MLExtra) +{ + U32 const seq1ML = 65539 + seq1MLExtra; + BYTE* op = block; + size_t i; + + /* Literals section header: raw literals, 3 byte header */ + MEM_writeLE24(op, (U32)(kSplitLitSize << 4) | (3 << 2)); + op += 3; + for (i = 0; i < kSplitLitSize; i++) { + *op++ = i < kSplitLitInDst ? (BYTE)(i + 1) : 0xCC; + } + + /* Sequences section header: 2 sequences, LL & OF RLE, ML predefined. + * LL code 24 (base 48, 4 extra bits), OF code 0 (repcode 1 = offset 1). */ + *op++ = 2; + *op++ = 0x50; + *op++ = 24; + *op++ = 0; + + /* Bitstream, read from the top down: ML initial state 57 (ML code 52: base 65539, + * 16 extra bits), sequence 1 ML extra bits, LL extra bits 0 (LL 48), + * ML state update to state 0 (ML 3), sequence 2 LL extra bits 1 (LL 49). */ + { U64 const bitStream = (1ULL << 36) | (57ULL << 30) | ((U64)seq1MLExtra << 14) | 1; + MEM_writeLE32(op, (U32)bitStream); + op[4] = (BYTE)(bitStream >> 32); + op += 5; + } + + { BYTE* eop = expected; + memcpy(eop, block + 3, 48); + eop += 48; + memset(eop, 48, seq1ML); + eop += seq1ML; + memcpy(eop, block + 3 + 48, 49); + eop += 49; + memset(eop, 97, 3); + eop += 3; + memcpy(eop, block + 3 + 97, kSplitLitSize - 97); + } + + assert((size_t)(op - block) <= kSplitLitBlockMaxSize); + return (size_t)(op - block); +} + +static size_t FUZ_splitLitBlockDecodedSize(U32 seq1MLExtra) +{ + return kSplitLitSize + 65539 + seq1MLExtra + 3; +} + +static void test_decompressSplitLitBuffer(unsigned tnb) +{ + /* The literals are split, rather than stored after the block's output, because + * dstCapacity <= ZSTD_BLOCKSIZE_MAX + litSize + 2 * WILDCOPY_OVERLENGTH. */ + size_t const dstCapacity = ZSTD_BLOCKSIZE_MAX + kSplitLitSize + 64; + size_t const srcCapacity = ZSTD_FRAMEHEADERSIZE_MAX + ZSTD_blockHeaderSize + kSplitLitBlockMaxSize; + /* seq1MLExtra = 65371 : The first match ends 18 bytes before the next literals, + * so the literals are copied to an overlapping position. + * seq1MLExtra = 65460 : The first match overwrites the next literals. */ + U32 const seq1MLExtras[] = { 65371, 65460 }; + BYTE* const src = (BYTE*)malloc(srcCapacity); + BYTE* const dst = (BYTE*)malloc(dstCapacity); + BYTE* const expected = (BYTE*)malloc(dstCapacity); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + size_t i; + int anySucceeded = 0; + CHECK(src != NULL && dst != NULL && expected != NULL && dctx != NULL); + + DISPLAYLEVEL(3, "test%3u : decompress block with split literals overrun by output : ", tnb); + for (i = 0; i < COUNTOF(seq1MLExtras); i++) { + size_t const decodedSize = FUZ_splitLitBlockDecodedSize(seq1MLExtras[i]); + size_t blockSize; + size_t r; + CHECK(decodedSize <= dstCapacity); + + /* The block API has no ZSTD_BLOCKSIZE_MAX limit on the decoded size, + * so the decoder must either reject the block or decode it correctly. */ + blockSize = FUZ_writeSplitLitBlock(src, expected, seq1MLExtras[i]); + CHECK_Z( ZSTD_decompressBegin(dctx) ); + r = ZSTD_decompressBlock(dctx, dst, dstCapacity, src, blockSize); + if (!ZSTD_isError(r)) { + CHECK_EQ(r, decodedSize); + CHECK(!memcmp(dst, expected, decodedSize)); + anySucceeded = 1; + } + + /* Wrap the block in a frame with windowLog 17, so blockSizeMax = ZSTD_BLOCKSIZE_MAX. + * The frame must be rejected, because the block decodes to more than blockSizeMax. */ + MEM_writeLE32(src, ZSTD_MAGICNUMBER); + src[4] = 0x00; /* Frame header descriptor: no content size, no checksum, no dictID */ + src[5] = 0x38; /* Window descriptor: windowLog 17 */ + blockSize = FUZ_writeSplitLitBlock(src + 9, expected, seq1MLExtras[i]); + MEM_writeLE24(src + 6, 1 /* last block */ | ((U32)bt_compressed << 1) | (U32)(blockSize << 3)); + r = ZSTD_decompress(dst, dstCapacity, src, 9 + blockSize); + CHECK(ZSTD_isError(r)); + } + CHECK(anySucceeded); /* Must not reject all blocks */ + DISPLAYLEVEL(3, "OK \n"); + + ZSTD_freeDCtx(dctx); + free(expected); + free(dst); + free(src); +} + static unsigned test_histCountWksp(unsigned seed, unsigned testNb) { static const unsigned symLowLimits[] = { 0, 27, 0, 0, 27, 42, 0, 0, 27, 42, 27, 42 }; @@ -1026,6 +1146,8 @@ static int basicUnitTests(U32 const seed, double compressibility) test_decompressBound(testNb++); + test_decompressSplitLitBuffer(testNb++); + test_setCParams(testNb++); DISPLAYLEVEL(3, "test%3u : ZSTD_adjustCParams : ", testNb++); @@ -1372,7 +1494,7 @@ static int basicUnitTests(U32 const seed, double compressibility) ZSTD_freeCCtx(cctx); } - DISPLAYLEVEL(3, "test%3i : maxBlockSize = 2K", testNb++); + DISPLAYLEVEL(3, "test%3i : maxBlockSize = 2K : ", testNb++); { ZSTD_CCtx* cctx = ZSTD_createCCtx(); ZSTD_DCtx* dctx = ZSTD_createDCtx(); @@ -1390,6 +1512,37 @@ static int basicUnitTests(U32 const seed, double compressibility) ZSTD_freeDCtx(dctx); ZSTD_freeCCtx(cctx); } + DISPLAYLEVEL(3, "OK \n"); + + DISPLAYLEVEL(3, "test%3i : maxBlockSize rejects raw & RLE blocks : ", testNb++); + { + ZSTD_CCtx* cctx = ZSTD_createCCtx(); + ZSTD_DCtx* dctx = ZSTD_createDCtx(); + size_t const srcSize = 8 KB; + BYTE* const src = (BYTE*)malloc(srcSize); + int i; + CHECK(src != NULL); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_maxBlockSize, 2048)); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_maxBlockSize, 1024)); + + /* i = 0 : incompressible data, which is stored in raw blocks + * i = 1 : a single repeated byte, which is stored in RLE blocks */ + for (i = 0; i < 2; i++) { + if (i == 0) { + RDG_genBuffer(src, srcSize, 0.0, 0.0, seed); + } else { + memset(src, 'a', srcSize); + } + cSize = ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, src, srcSize); + CHECK_Z(cSize); + CHECK(ZSTD_isError(ZSTD_decompressDCtx(dctx, decodedBuffer, CNBuffSize, compressedBuffer, cSize))); + } + + free(src); + ZSTD_freeDCtx(dctx); + ZSTD_freeCCtx(cctx); + } + DISPLAYLEVEL(3, "OK \n"); DISPLAYLEVEL(3, "test%3i : ldm fill dict out-of-bounds check", testNb++); { From 6c93b99d29953e4557196f85e500b3c5aae70725 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Mon, 9 Nov 2026 11:40:54 -0800 Subject: [PATCH 15/94] seekable_format: document ZSTD_seekable_decompress() at the end of the data The return value is smaller than dstSize when the data ends first, 0 when offset is the end of the data, and an error past it. --- contrib/seekable_format/zstd_seekable.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contrib/seekable_format/zstd_seekable.h b/contrib/seekable_format/zstd_seekable.h index b1f83d0e0a1..e24e4e33edd 100644 --- a/contrib/seekable_format/zstd_seekable.h +++ b/contrib/seekable_format/zstd_seekable.h @@ -152,6 +152,9 @@ ZSTDLIB_API size_t ZSTD_seekable_writeSeekTable(ZSTD_frameLog* fl, ZSTD_outBuffe * consecutive range of data, it will efficiently retain the decompressor object * and avoid redecompressing frame prefixes. The return value is the number of * bytes decompressed, or an error code checkable with ZSTD_isError(). +* It is smaller than `dstSize` only when the data ends before +* `offset + dstSize`, and it is 0 when `offset` is the end of the data. +* An `offset` past the end of the data is an error. * * The seek table access functions can be used to obtain the data contained * in the seek table. If frameIndex is larger than the value returned by From cec5da450609357bb24b29f1ed64571d6c31e190 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Mon, 9 Nov 2026 12:17:22 -0800 Subject: [PATCH 16/94] programs: don't write the output through a symbolic link The output file was opened with O_CREAT|O_TRUNC, which follows a symbolic link. stat() fails on a dangling link, so it was not seen as an existing file: zstd created its target and wrote the output there, even without -f. A new output file is now created with O_EXCL, which fails on any symbolic link, even a dangling one, including a link that appears after the existence check or after an existing file was removed. A dangling link now counts as an existing file: it is not overwritten without -f, and -f replaces the link, as it already did for a link to an existing file. Existing files that are not regular files, such as /dev/null, /dev/stdout or pipes, are still opened as they are. They are no longer truncated, and they are refused if they turn out to be regular files once opened. A regular file that could not be removed is still truncated in place. --- programs/fileio.c | 32 ++++++++++++++++--- ...-to-dir-without-write-perm.sh.stderr.exact | 6 ++-- .../compress-file-to-file.sh.stderr.glob | 6 ++-- .../compress-stdin-to-file.sh.stderr.glob | 6 ++-- .../decompress-file-to-file.sh.stderr.glob | 6 ++-- .../decompress-stdin-to-file.sh.stderr.glob | 6 ++-- tests/playTests.sh | 14 ++++++++ 7 files changed, 57 insertions(+), 19 deletions(-) diff --git a/programs/fileio.c b/programs/fileio.c index 714c447d4fd..20f99530cf2 100644 --- a/programs/fileio.c +++ b/programs/fileio.c @@ -850,6 +850,8 @@ FIO_openDstFile(FIO_ctx_t* fCtx, FIO_prefs_t* const prefs, const char* srcFileName, const char* dstFileName, const int mode) { + stat_t dstStat; + int dstExists; if (prefs->testMode) return NULL; /* do not open file in test mode */ assert(dstFileName != NULL); @@ -869,7 +871,11 @@ FIO_openDstFile(FIO_ctx_t* fCtx, FIO_prefs_t* const prefs, return NULL; } - if (UTIL_isRegularFile(dstFileName)) { + dstExists = UTIL_stat(dstFileName, &dstStat); + /* A dangling symbolic link counts as an existing file : + * writing through it would create its target. */ + if ( (dstExists && UTIL_isRegularFileStat(&dstStat)) + || (!dstExists && UTIL_isLink(dstFileName)) ) { /* Check if destination file already exists */ #if !defined(_WIN32) /* this test does not work on Windows : @@ -891,7 +897,12 @@ FIO_openDstFile(FIO_ctx_t* fCtx, FIO_prefs_t* const prefs, return NULL; } /* need to unlink */ - FIO_removeFile(dstFileName); + if (dstExists) { + /* still exists only if remove() failed */ + dstExists = FIO_removeFile(dstFileName) != 0; + } else { + (void)remove(dstFileName); /* dangling link */ + } } { @@ -906,9 +917,22 @@ FIO_openDstFile(FIO_ctx_t* fCtx, FIO_prefs_t* const prefs, f = _fdopen(fd, "wb"); } #else - const int openflags = O_WRONLY|O_CREAT|O_TRUNC; - const int fd = open(dstFileName, openflags, mode); + /* A new file is created with O_EXCL, which fails on any symbolic link, + * even a dangling one : output never goes to the target of a link. + * An existing file is either a regular file which could not be removed, + * or not a regular file (/dev/null, pipe) : it is opened as it is. */ + int const dstIsSpecial = dstExists && !UTIL_isRegularFileStat(&dstStat); + const int openflags = !dstExists ? O_WRONLY|O_CREAT|O_EXCL + : dstIsSpecial ? O_WRONLY + : O_WRONLY|O_TRUNC; + int fd = open(dstFileName, openflags, mode); FILE* f = NULL; + if (fd != -1 && dstIsSpecial && UTIL_isFdRegularFile(fd)) { + /* replaced by a regular file since UTIL_stat() : don't write into it */ + close(fd); + fd = -1; + errno = EEXIST; + } if (fd != -1) { f = fdopen(fd, "wb"); } diff --git a/tests/cli-tests/file-stat/compress-file-to-dir-without-write-perm.sh.stderr.exact b/tests/cli-tests/file-stat/compress-file-to-dir-without-write-perm.sh.stderr.exact index 05d91ffc9b7..390c792f3e6 100644 --- a/tests/cli-tests/file-stat/compress-file-to-dir-without-write-perm.sh.stderr.exact +++ b/tests/cli-tests/file-stat/compress-file-to-dir-without-write-perm.sh.stderr.exact @@ -18,9 +18,9 @@ Trace:FileStat: < 1 Trace:FileStat: > UTIL_stat(-1, out/file.zst) Trace:FileStat: < 0 Trace:FileStat: < 0 -Trace:FileStat: > UTIL_isRegularFile(out/file.zst) -Trace:FileStat: > UTIL_stat(-1, out/file.zst) -Trace:FileStat: < 0 +Trace:FileStat: > UTIL_stat(-1, out/file.zst) +Trace:FileStat: < 0 +Trace:FileStat: > UTIL_isLink(out/file.zst) Trace:FileStat: < 0 Trace:FileStat: > UTIL_isFdRegularFile(-1) Trace:FileStat: < 0 diff --git a/tests/cli-tests/file-stat/compress-file-to-file.sh.stderr.glob b/tests/cli-tests/file-stat/compress-file-to-file.sh.stderr.glob index 17a677deba4..3500d7b529b 100644 --- a/tests/cli-tests/file-stat/compress-file-to-file.sh.stderr.glob +++ b/tests/cli-tests/file-stat/compress-file-to-file.sh.stderr.glob @@ -18,9 +18,9 @@ Trace:FileStat: < 1 Trace:FileStat: > UTIL_stat(-1, file.zst) Trace:FileStat: < 0 Trace:FileStat: < 0 -Trace:FileStat: > UTIL_isRegularFile(file.zst) -Trace:FileStat: > UTIL_stat(-1, file.zst) -Trace:FileStat: < 0 +Trace:FileStat: > UTIL_stat(-1, file.zst) +Trace:FileStat: < 0 +Trace:FileStat: > UTIL_isLink(file.zst) Trace:FileStat: < 0 Trace:FileStat: > UTIL_isFdRegularFile(*) Trace:FileStat: > UTIL_stat(*, ) diff --git a/tests/cli-tests/file-stat/compress-stdin-to-file.sh.stderr.glob b/tests/cli-tests/file-stat/compress-stdin-to-file.sh.stderr.glob index 38732cbe995..b315792dc3a 100644 --- a/tests/cli-tests/file-stat/compress-stdin-to-file.sh.stderr.glob +++ b/tests/cli-tests/file-stat/compress-stdin-to-file.sh.stderr.glob @@ -10,9 +10,9 @@ Trace:FileStat: > UTIL_isSameFile(/*stdin*\, file.zst) Trace:FileStat: > UTIL_stat(-1, /*stdin*\) Trace:FileStat: < 0 Trace:FileStat: < 0 -Trace:FileStat: > UTIL_isRegularFile(file.zst) -Trace:FileStat: > UTIL_stat(-1, file.zst) -Trace:FileStat: < 0 +Trace:FileStat: > UTIL_stat(-1, file.zst) +Trace:FileStat: < 0 +Trace:FileStat: > UTIL_isLink(file.zst) Trace:FileStat: < 0 Trace:FileStat: > UTIL_isFdRegularFile(*) Trace:FileStat: > UTIL_stat(*, ) diff --git a/tests/cli-tests/file-stat/decompress-file-to-file.sh.stderr.glob b/tests/cli-tests/file-stat/decompress-file-to-file.sh.stderr.glob index ce194dfb376..2f5267451ac 100644 --- a/tests/cli-tests/file-stat/decompress-file-to-file.sh.stderr.glob +++ b/tests/cli-tests/file-stat/decompress-file-to-file.sh.stderr.glob @@ -18,9 +18,9 @@ Trace:FileStat: < 1 Trace:FileStat: > UTIL_stat(-1, file) Trace:FileStat: < 0 Trace:FileStat: < 0 -Trace:FileStat: > UTIL_isRegularFile(file) -Trace:FileStat: > UTIL_stat(-1, file) -Trace:FileStat: < 0 +Trace:FileStat: > UTIL_stat(-1, file) +Trace:FileStat: < 0 +Trace:FileStat: > UTIL_isLink(file) Trace:FileStat: < 0 Trace:FileStat: > UTIL_isFdRegularFile(*) Trace:FileStat: > UTIL_stat(*, ) diff --git a/tests/cli-tests/file-stat/decompress-stdin-to-file.sh.stderr.glob b/tests/cli-tests/file-stat/decompress-stdin-to-file.sh.stderr.glob index 9eb9208832f..88fd1af2f92 100644 --- a/tests/cli-tests/file-stat/decompress-stdin-to-file.sh.stderr.glob +++ b/tests/cli-tests/file-stat/decompress-stdin-to-file.sh.stderr.glob @@ -10,9 +10,9 @@ Trace:FileStat: > UTIL_isSameFile(/*stdin*\, file) Trace:FileStat: > UTIL_stat(-1, /*stdin*\) Trace:FileStat: < 0 Trace:FileStat: < 0 -Trace:FileStat: > UTIL_isRegularFile(file) -Trace:FileStat: > UTIL_stat(-1, file) -Trace:FileStat: < 0 +Trace:FileStat: > UTIL_stat(-1, file) +Trace:FileStat: < 0 +Trace:FileStat: > UTIL_isLink(file) Trace:FileStat: < 0 Trace:FileStat: > UTIL_isFdRegularFile(*) Trace:FileStat: > UTIL_stat(*, ) diff --git a/tests/playTests.sh b/tests/playTests.sh index f1c26c8d9a7..ba0500dee3a 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -1012,6 +1012,20 @@ zstd world.tmp hello.tmp -f test -f world.tmp.zst # symbolic link should have been compressed with --force rm -f hello.tmp world.tmp world2.tmp hello.tmp.zst world.tmp.zst +println "test : output is not written through a dangling symbolic link" +rm -f hello.tmp hello.tmp.zst tmpLinkTarget +println "hello world" > hello.tmp +zstd -q --rm hello.tmp +ln -s tmpLinkTarget hello.tmp +zstd -q -d hello.tmp.zst && die "output should not be written through a dangling link" +test ! -e tmpLinkTarget # link target should not have been created +zstd -q -d -f hello.tmp.zst # --force replaces the link +test ! -L hello.tmp +test -f hello.tmp +test ! -e tmpLinkTarget +zstd -q -d hello.tmp.zst -o /dev/null # existing non-regular files are still opened +rm -f hello.tmp hello.tmp.zst tmpLinkTarget + fi From 75dd154d3c7639a3aecd9b29832652ff205bca33 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Mon, 9 Nov 2026 16:22:29 -0800 Subject: [PATCH 17/94] doc: ZSTD_DCtx_refDDict() table is freed by reset and refDDict(NULL) --- lib/zstd.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/zstd.h b/lib/zstd.h index 97fef316fd6..ccf44d0cb28 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1167,8 +1167,9 @@ ZSTDLIB_API size_t ZSTD_DCtx_loadDictionary(ZSTD_DCtx* dctx, const void* dict, s * If called with ZSTD_d_refMultipleDDicts enabled, repeated calls of this function * will store the DDict references in a table, and the DDict used for decompression * will be determined at decompression time, as per the dict ID in the frame. - * The memory for the table is allocated on the first call to refDDict, and can be - * freed with ZSTD_freeDCtx(). + * The memory for the table is allocated on the first call to refDDict. + * The table is freed by ZSTD_DCtx_refDDict(dctx, NULL), by ZSTD_DCtx_reset() + * with ZSTD_reset_parameters or ZSTD_reset_session_and_parameters, and by ZSTD_freeDCtx(). * * If called with ZSTD_d_refMultipleDDicts disabled (the default), only one dictionary * will be managed, and referencing a dictionary effectively "discards" any previous one. From fd1f65c338788621d678ce3570d809a307526bb9 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Mon, 9 Nov 2026 16:38:37 -0800 Subject: [PATCH 18/94] tests: shorten the DDict hashset reset test Use the shared CCtx and the fixed dictionary for ddictB, compress the frame with ZSTD_compress_usingDict(), and run the reset and the refDDict(NULL) cases in one loop. --- tests/fuzzer.c | 89 +++++++++++++++----------------------------------- 1 file changed, 26 insertions(+), 63 deletions(-) diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 8978bcc5ec2..0ff1c582778 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3942,76 +3942,39 @@ static int basicUnitTests(U32 const seed, double compressibility) DISPLAYLEVEL(3, "OK (ASAN would report OOB write if vulnerable)\n"); DISPLAYLEVEL(3, "test%3i : DDict hashset is dropped by reset and refDDict(NULL) : ", testNb++); - { - /* After ZSTD_reset_session_and_parameters, the DCtx must not keep - * pointers to DDicts the caller has since freed. */ - const char msg[] = "reset the ddict hashset, reset the ddict hashset"; - char frame[256]; - char out[256]; - size_t frameSize; - size_t r; - ZSTD_DCtx* const dctx = ZSTD_createDCtx(); - ZSTD_CCtx* const cctx2 = ZSTD_createCCtx(); + { /* After a parameter reset or refDDict(NULL), a frame must not select + * a DDict referenced before, which the caller may have freed since */ char* const dictBufA = (char*)malloc(dictBufferFixedSize); - char* const dictBufB = (char*)malloc(dictBufferFixedSize); - ZSTD_CDict* cdictA = NULL; - ZSTD_DDict* ddictA = NULL; - ZSTD_DDict* ddictB = NULL; - - if (!dctx || !cctx2 || !dictBufA || !dictBufB) { - DISPLAY("alloc failed\n"); - goto _output_error; - } + ZSTD_DDict* const ddictB = ZSTD_createDDict(dictBufferFixed, dictBufferFixedSize); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + size_t frameSize; + int n; + if (!dictBufA || !ddictB || !dctx) goto _output_error; ZSTD_memcpy(dictBufA, dictBufferFixed, dictBufferFixedSize); - ZSTD_memcpy(dictBufB, dictBufferFixed, dictBufferFixedSize); - MEM_writeLE32(dictBufA + ZSTD_FRAMEIDSIZE, 101); - MEM_writeLE32(dictBufB + ZSTD_FRAMEIDSIZE, 102); - cdictA = ZSTD_createCDict(dictBufA, dictBufferFixedSize, 1); - ddictA = ZSTD_createDDict(dictBufA, dictBufferFixedSize); - ddictB = ZSTD_createDDict(dictBufB, dictBufferFixedSize); - if (!cdictA || !ddictA || !ddictB) { - DISPLAY("dictionary creation failed\n"); - goto _output_error; - } - frameSize = ZSTD_compress_usingCDict(cctx2, frame, sizeof(frame), msg, sizeof(msg), cdictA); - if (ZSTD_isError(frameSize)) goto _output_error; - - CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); - CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictA) ); - CHECK_Z( ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters) ); - ZSTD_freeDDict(ddictA); - ddictA = NULL; - - /* Only ddictB is referenced now: a frame that asks for dictID 101 - * must be rejected, and must not look at the freed ddictA. */ - CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); - CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictB) ); - r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, frameSize); - if (!ZSTD_isError(r)) { - DISPLAY("frame decoded with a dictionary that was reset away\n"); - goto _output_error; - } - - /* Same thing for ZSTD_DCtx_refDDict(dctx, NULL). */ - { ZSTD_DDict* const ddictA2 = ZSTD_createDDict(dictBufA, dictBufferFixedSize); - if (!ddictA2) goto _output_error; - CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictA2) ); - CHECK_Z( ZSTD_DCtx_refDDict(dctx, NULL) ); - ZSTD_freeDDict(ddictA2); + MEM_writeLE32(dictBufA + ZSTD_FRAMEIDSIZE, ZSTD_getDictID_fromDDict(ddictB) + 1); + frameSize = ZSTD_compress_usingDict(cctx, compressedBuffer, compressedBufferSize, + CNBuffer, 100, dictBufA, dictBufferFixedSize, 1); + CHECK_Z(frameSize); + for (n = 0; n < 2; n++) { + ZSTD_DDict* const ddictA = ZSTD_createDDict(dictBufA, dictBufferFixedSize); + if (!ddictA) goto _output_error; + CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); + CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictA) ); + if (n == 0) { + CHECK_Z( ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters) ); + } else { + CHECK_Z( ZSTD_DCtx_refDDict(dctx, NULL) ); + } + ZSTD_freeDDict(ddictA); + /* only ddictB is referenced now : the frame needs ddictA, so it must fail */ + CHECK_Z( ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, ZSTD_rmd_refMultipleDDicts) ); CHECK_Z( ZSTD_DCtx_refDDict(dctx, ddictB) ); - r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, frameSize); - if (!ZSTD_isError(r)) { - DISPLAY("frame decoded with a dictionary that was unreferenced\n"); + if (!ZSTD_isError(ZSTD_decompressDCtx(dctx, decodedBuffer, CNBuffSize, compressedBuffer, frameSize))) goto _output_error; - } } - - ZSTD_freeDDict(ddictB); - ZSTD_freeCDict(cdictA); - ZSTD_freeCCtx(cctx2); ZSTD_freeDCtx(dctx); + ZSTD_freeDDict(ddictB); free(dictBufA); - free(dictBufB); } DISPLAYLEVEL(3, "OK \n"); From 54ade3a78b1fb2ebabfeff69012b8aafce75bb54 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 10:02:04 -0800 Subject: [PATCH 19/94] compress: wait for an empty job before releasing it ZSTDMT_waitForAllJobsCompleted() waited while consumed < src.size. An empty job, the only job of an empty frame, meets that condition before its worker starts, so the wait returned at once. The previous commit calls ZSTDMT_waitForPendingJobs() at the start of ZSTDMT_freeCCtx(), before POOL_free() joins the workers. When an empty frame was abandoned (ZSTD_e_end returned because the output buffer was full), freeing the context cleared the job while its worker started, and the worker dereferenced a NULL pool. Before that commit, POOL_free() joined the worker first. The worker now sets a completed flag when it no longer uses the job, and the wait uses this flag. The test gets a scenario for this case. --- lib/compress/zstdmt_compress.c | 8 ++++++-- tests/zstreamtest.c | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/compress/zstdmt_compress.c b/lib/compress/zstdmt_compress.c index 1594221c4e9..272fd5d27b7 100644 --- a/lib/compress/zstdmt_compress.c +++ b/lib/compress/zstdmt_compress.c @@ -662,6 +662,7 @@ static const Range kNullRange = { NULL, 0 }; typedef struct { size_t consumed; /* SHARED - set0 by mtctx, then modified by worker AND read by mtctx */ size_t cSize; /* SHARED - set0 by mtctx, then modified by worker AND read by mtctx, then set0 by mtctx */ + unsigned completed; /* SHARED - set0 by mtctx, then set1 by worker when it no longer uses the job */ ZSTD_pthread_mutex_t job_mutex; /* Thread-safe - used by mtctx and worker */ ZSTD_pthread_cond_t job_cond; /* Thread-safe - used by mtctx and worker */ ZSTDMT_CCtxPool* cctxPool; /* Thread-safe - used by mtctx and (all) workers */ @@ -814,7 +815,8 @@ static void ZSTDMT_compressionJob(void* jobDescription) ZSTD_PTHREAD_MUTEX_LOCK(&job->job_mutex); if (ZSTD_isError(job->cSize)) assert(lastCBlockSize == 0); job->cSize += lastCBlockSize; - job->consumed = job->src.size; /* when job->consumed == job->src.size , compression job is presumed completed */ + job->consumed = job->src.size; + job->completed = 1; /* also true for an empty job, unlike consumed == src.size */ ZSTD_pthread_cond_signal(&job->job_cond); ZSTD_pthread_mutex_unlock(&job->job_mutex); } @@ -1034,7 +1036,7 @@ static void ZSTDMT_waitForAllJobsCompleted(ZSTDMT_CCtx* mtctx) while (mtctx->doneJobID < mtctx->nextJobID) { unsigned const jobID = mtctx->doneJobID & mtctx->jobIDMask; ZSTD_PTHREAD_MUTEX_LOCK(&mtctx->jobs[jobID].job_mutex); - while (mtctx->jobs[jobID].consumed < mtctx->jobs[jobID].src.size) { + while (!mtctx->jobs[jobID].completed) { DEBUGLOG(4, "waiting for jobCompleted signal from job %u", mtctx->doneJobID); /* we want to block when waiting for data to flush */ ZSTD_pthread_cond_wait(&mtctx->jobs[jobID].job_cond, &mtctx->jobs[jobID].job_mutex); } @@ -1394,6 +1396,7 @@ static void ZSTDMT_writeLastEmptyBlock(ZSTDMT_jobDescription* job) assert(job->src.size == 0); /* last job is empty -> will be simplified into a last empty block */ assert(job->firstJob == 0); /* cannot be first job, as it also needs to create frame header */ assert(job->dstBuff.start == NULL); /* invoked from streaming variant only (otherwise, dstBuff might be user's output) */ + job->completed = 1; /* written here, never posted to a worker */ job->dstBuff = ZSTDMT_getBuffer(job->bufPool); if (job->dstBuff.start == NULL) { job->cSize = ERROR(memory_allocation); @@ -1427,6 +1430,7 @@ static size_t ZSTDMT_createCompressionJob(ZSTDMT_CCtx* mtctx, size_t srcSize, ZS mtctx->jobs[jobID].prefix = mtctx->inBuff.prefix; mtctx->jobs[jobID].consumed = 0; mtctx->jobs[jobID].cSize = 0; + mtctx->jobs[jobID].completed = 0; mtctx->jobs[jobID].params = mtctx->params; mtctx->jobs[jobID].cdict = mtctx->nextJobID==0 ? mtctx->cdict : NULL; mtctx->jobs[jobID].fullFrameSize = mtctx->frameContentSize; diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index 22b038828cd..82d5ebf3307 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -2521,10 +2521,10 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) size_t const largeDictSize = 1 MB; const void* const largeDict = (const char*)CNBuffer + srcSize; int scenario; - for (scenario = 0; scenario < 4; scenario++) { + for (scenario = 0; scenario < 5; scenario++) { ZSTD_CCtx* const cctx = ZSTD_createCCtx(); ZSTD_threadPool* const pool = (scenario == 1) ? ZSTD_createThreadPool(1) : NULL; - ZSTD_inBuffer in = { CNBuffer, srcSize, 0 }; + ZSTD_inBuffer in = { CNBuffer, (scenario == 2) ? 0 : srcSize, 0 }; ZSTD_outBuffer out = { compressedBuffer, compressedBufferSize, 0 }; size_t nextDictSize = largeDictSize; CHECK(cctx == NULL, "ZSTD_createCCtx failed"); @@ -2534,11 +2534,21 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) if (pool != NULL) CHECK_Z( ZSTD_CCtx_refThreadPool(cctx, pool) ); CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, largeDictSize) ); CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); - CHECK(in.pos < jobSize, "the first job should be in progress"); - if (scenario >= 2) { + if (scenario == 2) { + /* empty frame : its only job is empty, and is still in progress + * when ZSTD_e_end returns because the output buffer is full */ + size_t remaining; + out.size = out.pos; + remaining = ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_end); + CHECK_Z(remaining); + CHECK(remaining == 0, "the empty frame should not be completed"); + } else { + CHECK(in.pos < jobSize, "the first job should be in progress"); + } + if (scenario >= 3) { /* abandon the frame, then change the dictionary or the number of workers */ CHECK_Z( ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only) ); - if (scenario == 2) { + if (scenario == 3) { nextDictSize = largeDictSize / 2; CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, nextDictSize) ); } else { @@ -2552,7 +2562,7 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) CHECK(dSize != srcSize, "wrong decompressed size"); CHECK(memcmp(decodedBuffer, CNBuffer, srcSize), "decompressed data must match"); } } - ZSTD_freeCCtx(cctx); /* scenarios 0 and 1 free it with the job in progress */ + ZSTD_freeCCtx(cctx); /* scenarios 0 to 2 free it with the job in progress */ ZSTD_freeThreadPool(pool); } } From a8af53bb32ba69a4d17f24e58f4459c64235e1a6 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 10:04:58 -0800 Subject: [PATCH 20/94] compress: clear jobReady when the jobs are released ZSTDMT_releaseAllJobResources() clears every job descriptor, including the job prepared while no worker was free (jobReady), but it left jobReady set. After a frame was abandoned in that state, for example with ZSTD_CCtx_reset(), the first ZSTDMT_createCompressionJob() of the next frame posted the cleared descriptor, and the worker dereferenced a NULL pool. The test gets a scenario for this case. --- lib/compress/zstdmt_compress.c | 1 + tests/zstreamtest.c | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/compress/zstdmt_compress.c b/lib/compress/zstdmt_compress.c index 272fd5d27b7..7118bbe7f4a 100644 --- a/lib/compress/zstdmt_compress.c +++ b/lib/compress/zstdmt_compress.c @@ -1027,6 +1027,7 @@ static void ZSTDMT_releaseAllJobResources(ZSTDMT_CCtx* mtctx) } mtctx->inBuff.buffer = g_nullBuffer; mtctx->inBuff.filled = 0; + mtctx->jobReady = 0; /* the prepared job was cleared above */ mtctx->allJobsCompleted = 1; } diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index 82d5ebf3307..b7360327ef5 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -2521,7 +2521,7 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) size_t const largeDictSize = 1 MB; const void* const largeDict = (const char*)CNBuffer + srcSize; int scenario; - for (scenario = 0; scenario < 5; scenario++) { + for (scenario = 0; scenario < 6; scenario++) { ZSTD_CCtx* const cctx = ZSTD_createCCtx(); ZSTD_threadPool* const pool = (scenario == 1) ? ZSTD_createThreadPool(1) : NULL; ZSTD_inBuffer in = { CNBuffer, (scenario == 2) ? 0 : srcSize, 0 }; @@ -2545,13 +2545,18 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) } else { CHECK(in.pos < jobSize, "the first job should be in progress"); } + if (scenario == 5) { + /* the next job is prepared, and waits until the worker is free */ + in.size = 2 * jobSize; + CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); + } if (scenario >= 3) { - /* abandon the frame, then change the dictionary or the number of workers */ + /* abandon the frame, then change the dictionary or the number of workers, or nothing */ CHECK_Z( ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only) ); if (scenario == 3) { nextDictSize = largeDictSize / 2; CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, nextDictSize) ); - } else { + } else if (scenario == 4) { CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 4) ); } cSize = ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, srcSize); From 8136851284d424518e98cfdf1f4b370e73094c32 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 10:57:16 -0800 Subject: [PATCH 21/94] tests: shorten the abandoned multi-threaded frame test Remove the scenario that raises nbWorkers after a reset. Since ZSTD_CCtx_reset() waits for the jobs, this scenario can only fail when the one that loads a new dictionary after a reset fails too. The new frames are compressed, but no longer decompressed: the bugs show as memory errors in the worker. Each of the five scenarios still fails when its library change is removed. --- tests/zstreamtest.c | 49 ++++++++++++++------------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index b7360327ef5..f671dea4451 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -2517,57 +2517,38 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) * since they use its dictionary, pools and jobs table */ DISPLAYLEVEL(3, "test%3i : abandon a multi-threaded frame with a job in progress : ", testNb++); { size_t const jobSize = 1 MB; - size_t const srcSize = jobSize + 64 KB; /* starts one job, the rest stays buffered */ - size_t const largeDictSize = 1 MB; - const void* const largeDict = (const char*)CNBuffer + srcSize; + const void* const dict = (const char*)CNBuffer + 2 * jobSize; int scenario; - for (scenario = 0; scenario < 6; scenario++) { + for (scenario = 0; scenario < 5; scenario++) { ZSTD_CCtx* const cctx = ZSTD_createCCtx(); ZSTD_threadPool* const pool = (scenario == 1) ? ZSTD_createThreadPool(1) : NULL; - ZSTD_inBuffer in = { CNBuffer, (scenario == 2) ? 0 : srcSize, 0 }; + ZSTD_inBuffer in = { CNBuffer, (scenario == 2) ? 0 : jobSize, 0 }; ZSTD_outBuffer out = { compressedBuffer, compressedBufferSize, 0 }; - size_t nextDictSize = largeDictSize; CHECK(cctx == NULL, "ZSTD_createCCtx failed"); CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 10) ); CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 1) ); CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_jobSize, (int)jobSize) ); if (pool != NULL) CHECK_Z( ZSTD_CCtx_refThreadPool(cctx, pool) ); - CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, largeDictSize) ); - CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); + CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, dict, 1 MB) ); /* makes the first job slow */ + CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); /* starts the first job, if any */ if (scenario == 2) { - /* empty frame : its only job is empty, and is still in progress - * when ZSTD_e_end returns because the output buffer is full */ - size_t remaining; + /* empty frame : its only job is empty, and still runs when + * ZSTD_e_end returns because the output buffer is full */ out.size = out.pos; - remaining = ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_end); - CHECK_Z(remaining); - CHECK(remaining == 0, "the empty frame should not be completed"); - } else { - CHECK(in.pos < jobSize, "the first job should be in progress"); + CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_end) ); } - if (scenario == 5) { - /* the next job is prepared, and waits until the worker is free */ + if (scenario == 4) { + /* the second job is prepared, and waits until the worker is free */ in.size = 2 * jobSize; CHECK_Z( ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue) ); } if (scenario >= 3) { - /* abandon the frame, then change the dictionary or the number of workers, or nothing */ + /* abandon the frame, change the dictionary, and compress a new frame */ CHECK_Z( ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only) ); - if (scenario == 3) { - nextDictSize = largeDictSize / 2; - CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, largeDict, nextDictSize) ); - } else if (scenario == 4) { - CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 4) ); - } - cSize = ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, srcSize); - CHECK_Z(cSize); - { size_t const dSize = ZSTD_decompress_usingDict(zd, decodedBuffer, decodedBufferSize, - compressedBuffer, cSize, largeDict, nextDictSize); - CHECK_Z(dSize); - CHECK(dSize != srcSize, "wrong decompressed size"); - CHECK(memcmp(decodedBuffer, CNBuffer, srcSize), "decompressed data must match"); - } } - ZSTD_freeCCtx(cctx); /* scenarios 0 to 2 free it with the job in progress */ + CHECK_Z( ZSTD_CCtx_loadDictionary(cctx, dict, 512 KB) ); + CHECK_Z( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, jobSize) ); + } + ZSTD_freeCCtx(cctx); /* scenarios 0 to 2 free it with a job in progress */ ZSTD_freeThreadPool(pool); } } From 7e877d83a2acbaa0a659158a2893fbfb87503f96 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Mon, 18 May 2026 10:58:55 -0400 Subject: [PATCH 22/94] [zstd] Fix typo in spec --- doc/zstd_compression_format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/zstd_compression_format.md b/doc/zstd_compression_format.md index cdad14f5866..4827ce3e34a 100644 --- a/doc/zstd_compression_format.md +++ b/doc/zstd_compression_format.md @@ -489,7 +489,7 @@ __`Size_Format`__ - For `Raw_Literals_Block` and `RLE_Literals_Block`, it's only necessary to decode `Regenerated_Size`. There is no `Compressed_Size` field. -- For `Compressed_Block` and `Treeless_Literals_Block`, +- For `Compressed_Literals_Block` and `Treeless_Literals_Block`, it's required to decode both `Compressed_Size` and `Regenerated_Size` (the decompressed size). It's also necessary to decode the number of streams (1 or 4). From a2026f948c533ff42138d4cf0fdc0b4089c0d677 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 12:28:04 -0800 Subject: [PATCH 23/94] compress: stop using the previous pool after ZSTD_CCtx_refThreadPool() ZSTD_CCtx_refThreadPool() only changed cctx->pool. Once created, the MT context kept the pool it was created with, so the next frames still ran on the previous pool, including after ZSTD_CCtx_refThreadPool(cctx, NULL). If the caller then freed the previous pool, the next frame used freed memory. ZSTD_CCtx_refThreadPool() now frees the MT context when the pool changes, and the next frame creates one with the new pool. The pool can only change between frames, and ZSTDMT_freeCCtx() waits for the jobs of an abandoned frame. --- lib/compress/zstd_compress.c | 9 +++++++++ lib/zstd.h | 2 +- tests/fuzzer.c | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 88e1df51232..c51fd1853ff 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -1360,6 +1360,15 @@ size_t ZSTD_CCtx_refThreadPool(ZSTD_CCtx* cctx, ZSTD_threadPool* pool) { RETURN_ERROR_IF(cctx->streamStage != zcss_init, stage_wrong, "Can't ref a pool when ctx not in init stage."); +#ifdef ZSTD_MULTITHREAD + if (pool != cctx->pool) { + /* The MT context, created by the first multi-threaded frame and then reused, + * runs its jobs on the pool it was created with : without this, the next frames + * would still use the previous pool, which the caller may free. No job is running : + * the stage is zcss_init, and ZSTDMT_freeCCtx() waits for pending jobs. */ + ZSTDMT_freeCCtx(cctx->mtctx); cctx->mtctx = NULL; + } +#endif cctx->pool = pool; return 0; } diff --git a/lib/zstd.h b/lib/zstd.h index d06c05edd80..8d50c039383 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1901,7 +1901,7 @@ ZSTDLIB_STATIC_API ZSTD_CDict* ZSTD_createCDict_advanced(const void* dict, size_ * ZSTD_createThreadPool creates a new thread pool with a given number of threads. * Note that the lifetime of such pool must exist while being used. * ZSTD_CCtx_refThreadPool assigns a thread pool to a context (use NULL argument value - * to use an internal thread pool). + * to use an internal thread pool). The context then no longer uses its previous pool. * ZSTD_freeThreadPool frees a thread pool, accepts NULL pointer. */ typedef struct POOL_ctx_s ZSTD_threadPool; diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 3d3cd66abf8..9e955b77f8b 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -483,6 +483,25 @@ static int threadPoolTests(void) { } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "thread pool test : free the previous pool after a rebind: "); + { ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + ZSTD_threadPool* const pool1 = ZSTD_createThreadPool(1); + ZSTD_threadPool* const pool2 = ZSTD_createThreadPool(1); + CHECK(cctx != NULL && pool1 != NULL && pool2 != NULL); + CHECK(!ZSTD_isError( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 2) )); + CHECK(!ZSTD_isError( ZSTD_CCtx_refThreadPool(cctx, pool1) )); + CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); + /* the next frames must use pool2, then an internal pool */ + CHECK(!ZSTD_isError( ZSTD_CCtx_refThreadPool(cctx, pool2) )); + ZSTD_freeThreadPool(pool1); + CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); + CHECK(!ZSTD_isError( ZSTD_CCtx_refThreadPool(cctx, NULL) )); + ZSTD_freeThreadPool(pool2); + CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); + ZSTD_freeCCtx(cctx); + } + DISPLAYLEVEL(3, "OK \n"); + _end: free(CNBuffer); free(compressedBuffer); From 31db1af2942e393bab01c3967f59ca9f8efa9b02 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 12:30:33 -0800 Subject: [PATCH 24/94] compress: don't resize a thread pool provided by the application When ZSTD_c_nbWorkers changed between two frames, ZSTDMT_resize() called POOL_resize() on the context's pool, even on a pool given with ZSTD_CCtx_refThreadPool(). One context could then change the number of threads of a pool shared with other contexts, although such a pool is meant to limit them. ZSTDMT_createCCtx_advanced() already leaves a provided pool unchanged. ZSTDMT_resize() now does the same. --- lib/compress/zstdmt_compress.c | 4 +++- lib/zstd.h | 1 + tests/fuzzer.c | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/compress/zstdmt_compress.c b/lib/compress/zstdmt_compress.c index 7118bbe7f4a..38ad31d0e0d 100644 --- a/lib/compress/zstdmt_compress.c +++ b/lib/compress/zstdmt_compress.c @@ -1091,7 +1091,9 @@ size_t ZSTDMT_sizeof_CCtx(ZSTDMT_CCtx* mtctx) * @return : error code if fails, 0 on success */ static size_t ZSTDMT_resize(ZSTDMT_CCtx* mtctx, unsigned nbWorkers) { - if (POOL_resize(mtctx->factory, nbWorkers)) return ERROR(memory_allocation); + if (!mtctx->providedFactory) { /* a pool provided by the application keeps its size */ + if (POOL_resize(mtctx->factory, nbWorkers)) return ERROR(memory_allocation); + } FORWARD_IF_ERROR( ZSTDMT_expandJobsTable(mtctx, nbWorkers) , ""); mtctx->bufPool = ZSTDMT_expandBufferPool(mtctx->bufPool, BUF_POOL_MAX_NB_BUFFERS(nbWorkers)); if (mtctx->bufPool == NULL) return ERROR(memory_allocation); diff --git a/lib/zstd.h b/lib/zstd.h index 8d50c039383..e0862eb3fa8 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1899,6 +1899,7 @@ ZSTDLIB_STATIC_API ZSTD_CDict* ZSTD_createCDict_advanced(const void* dict, size_ * This can limit resources for applications with multiple threads where each one uses * a threaded compression mode (via ZSTD_c_nbWorkers parameter). * ZSTD_createThreadPool creates a new thread pool with a given number of threads. + * ZSTD_c_nbWorkers does not change the number of threads of such a pool. * Note that the lifetime of such pool must exist while being used. * ZSTD_CCtx_refThreadPool assigns a thread pool to a context (use NULL argument value * to use an internal thread pool). The context then no longer uses its previous pool. diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 9e955b77f8b..cbc324a98ca 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -44,6 +44,7 @@ /* must be included after util.h, due to ERROR macro redefinition issue on Visual Studio */ #include "zstd_internal.h" /* ZSTD_WORKSPACETOOLARGE_MAXDURATION, ZSTD_WORKSPACETOOLARGE_FACTOR, KB, MB */ #include "threading.h" /* ZSTD_pthread_create, ZSTD_pthread_join */ +#include "pool.h" /* POOL_sizeof */ #include "compress/hist.h" /* HIST_count_wksp */ #include "compress/zstd_compress_internal.h" /* ZSTD_get1BlockSummary */ @@ -483,7 +484,7 @@ static int threadPoolTests(void) { } DISPLAYLEVEL(3, "OK \n"); - DISPLAYLEVEL(3, "thread pool test : free the previous pool after a rebind: "); + DISPLAYLEVEL(3, "thread pool test : change the pool and nbWorkers of a context: "); { ZSTD_CCtx* const cctx = ZSTD_createCCtx(); ZSTD_threadPool* const pool1 = ZSTD_createThreadPool(1); ZSTD_threadPool* const pool2 = ZSTD_createThreadPool(1); @@ -495,6 +496,12 @@ static int threadPoolTests(void) { CHECK(!ZSTD_isError( ZSTD_CCtx_refThreadPool(cctx, pool2) )); ZSTD_freeThreadPool(pool1); CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); + { size_t const pool2Size = POOL_sizeof(pool2); + /* the context must not resize a pool it shares */ + CHECK(!ZSTD_isError( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, 4) )); + CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); + CHECK(POOL_sizeof(pool2) == pool2Size); + } CHECK(!ZSTD_isError( ZSTD_CCtx_refThreadPool(cctx, NULL) )); ZSTD_freeThreadPool(pool2); CHECK(!ZSTD_isError( ZSTD_compress2(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize) )); From c3e2c0d4c8b31224a619a459f5d8d6c9b58f3084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Wed, 11 Nov 2026 00:33:09 +0300 Subject: [PATCH 25/94] Fix out-of-bounds read in FASTCOVER on small training samples (#4710) * Fix out-of-bounds read in FASTCOVER on small training samples When every training sample is smaller than the dmer size, splitPoint < 1 can shrink the training set below MAX(d, 8) after the total samples size check passes, so nbDmers underflows and FASTCOVER reads past the sample buffer (#4499). It now returns srcSize_wrong. Adds a unit test in basicUnitTests. The same underflow in COVER_ctx_init is addressed separately in #4690. * Name the FASTCOVER dmer read length --- lib/dictBuilder/fastcover.c | 11 +++++++++-- tests/fuzzer.c | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/dictBuilder/fastcover.c b/lib/dictBuilder/fastcover.c index 56a08138520..f59170ece60 100644 --- a/lib/dictBuilder/fastcover.c +++ b/lib/dictBuilder/fastcover.c @@ -317,10 +317,11 @@ FASTCOVER_ctx_init(FASTCOVER_ctx_t* ctx, const unsigned nbTestSamples = splitPoint < 1.0 ? nbSamples - nbTrainSamples : nbSamples; const size_t trainingSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes, nbTrainSamples) : totalSamplesSize; const size_t testSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes + nbTrainSamples, nbTestSamples) : totalSamplesSize; + const size_t readLength = MAX(d, sizeof(U64)); ctx->displayLevel = displayLevel; /* Checks */ - if (totalSamplesSize < MAX(d, sizeof(U64)) || + if (totalSamplesSize < readLength || totalSamplesSize >= (size_t)FASTCOVER_MAX_SAMPLES_SIZE) { DISPLAYLEVEL(1, "Total samples size is too large (%u MB), maximum size is %u MB\n", (unsigned)(totalSamplesSize >> 20), (FASTCOVER_MAX_SAMPLES_SIZE >> 20)); @@ -339,6 +340,12 @@ FASTCOVER_ctx_init(FASTCOVER_ctx_t* ctx, return ERROR(srcSize_wrong); } + /* Check the training set can hold at least one dmer, otherwise nbDmers underflows below */ + if (trainingSamplesSize < readLength) { + DISPLAYLEVEL(1, "Total training samples size is %u and is invalid.\n", (unsigned)trainingSamplesSize); + return ERROR(srcSize_wrong); + } + /* Zero the context */ memset(ctx, 0, sizeof(*ctx)); DISPLAYLEVEL(2, "Training on %u samples of total size %u\n", nbTrainSamples, @@ -351,7 +358,7 @@ FASTCOVER_ctx_init(FASTCOVER_ctx_t* ctx, ctx->nbSamples = nbSamples; ctx->nbTrainSamples = nbTrainSamples; ctx->nbTestSamples = nbTestSamples; - ctx->nbDmers = trainingSamplesSize - MAX(d, sizeof(U64)) + 1; + ctx->nbDmers = trainingSamplesSize - readLength + 1; ctx->d = d; ctx->f = f; ctx->accelParams = accelParams; diff --git a/tests/fuzzer.c b/tests/fuzzer.c index cbc324a98ca..dde20237781 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3239,6 +3239,16 @@ static int basicUnitTests(U32 const seed, double compressibility) if (ZDICT_isError(dictSize)) goto _output_error; DISPLAYLEVEL(3, "OK, created dictionary of size %u \n", (unsigned)dictSize); + DISPLAYLEVEL(3, "test%3i : FASTCOVER dictBuilder samples smaller than a dmer (issue #4499) : ", testNb++); + { const char smallData[8] = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H' }; + const size_t smallSizes[8] = { 1, 1, 1, 1, 1, 1, 1, 1 }; + size_t const r = ZDICT_trainFromBuffer(dictBuffer, dictBufferCapacity, + smallData, smallSizes, 8); + if (!ZDICT_isError(r)) goto _output_error; + if (ZSTD_getErrorCode(r) != ZSTD_error_srcSize_wrong) goto _output_error; + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : Multithreaded COVER dictBuilder : ", testNb++); { U32 u; for (u=0; u Date: Wed, 11 Nov 2026 03:11:47 +0530 Subject: [PATCH 26/94] Fix size_t underflow in COVER_ctx_init for small training sets (#4690) * Fix size_t underflow in COVER_ctx_init for small training sets When splitPoint < 1.0, the COVER dictionary builder trains on a subset of the corpus, so trainingSamplesSize can be smaller than the total even after the existing totalSamplesSize check. The partial suffix array size is computed as `trainingSamplesSize - MAX(d, sizeof(U64)) + 1`, which underflows size_t when the training set is too small, producing an enormous allocation request instead of a clean error. Guard against an undersized training set before the subtraction and return srcSize_wrong, and add a regression test in fuzzer.c. Fixes #4682. * Factor out COVER read length calculation --------- Co-authored-by: Kevin Zhang --- lib/dictBuilder/cover.c | 15 +++++++++++++-- tests/fuzzer.c | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index 06d1cb93a5c..e464b22fe56 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -636,14 +636,25 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, const unsigned nbTestSamples = splitPoint < 1.0 ? nbSamples - nbTrainSamples : nbSamples; const size_t trainingSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes, nbTrainSamples) : totalSamplesSize; const size_t testSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes + nbTrainSamples, nbTestSamples) : totalSamplesSize; + const size_t readLength = MAX(d, sizeof(U64); ctx->displayLevel = displayLevel; /* Checks */ - if (totalSamplesSize < MAX(d, sizeof(U64)) || + if (totalSamplesSize < readLength) || totalSamplesSize >= (size_t)COVER_MAX_SAMPLES_SIZE) { DISPLAYLEVEL(1, "Total samples size is too large (%u MB), maximum size is %u MB\n", (unsigned)(totalSamplesSize>>20), (COVER_MAX_SAMPLES_SIZE >> 20)); return ERROR(srcSize_wrong); } + /* The training set must be large enough for the partial suffix array, whose + * size is `trainingSamplesSize - MAX(d, sizeof(U64)) + 1`. When splitPoint + * < 1.0 the training set is a subset of the corpus, so it can be smaller + * than the total even after the check above. Guard against the size_t + * subtraction underflowing into a huge allocation. */ + if (trainingSamplesSize < MAX(d, sizeof(U64))) { + DISPLAYLEVEL(1, "Training samples size is too small (%u), minimum size is %u\n", + (unsigned)trainingSamplesSize, (unsigned)MAX(d, sizeof(U64))); + return ERROR(srcSize_wrong); + } /* Check if there are at least 5 training samples */ if (nbTrainSamples < 5) { DISPLAYLEVEL(1, "Total number of training samples is %u and is invalid.", nbTrainSamples); @@ -666,7 +677,7 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, ctx->nbTrainSamples = nbTrainSamples; ctx->nbTestSamples = nbTestSamples; /* Partial suffix array */ - ctx->suffixSize = trainingSamplesSize - MAX(d, sizeof(U64)) + 1; + ctx->suffixSize = trainingSamplesSize - readLength + 1; ctx->suffix = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); /* Maps index to the dmerID */ ctx->dmerAt = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index dde20237781..f63eab33a56 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4221,6 +4221,25 @@ static int basicUnitTests(U32 const seed, double compressibility) if (dictID==0) goto _output_error; DISPLAYLEVEL(3, "OK : %u \n", (unsigned)dictID); + DISPLAYLEVEL(3, "test%3i : ZDICT_optimizeTrainFromBuffer_cover with tiny training set : ", testNb++); + { /* With splitPoint < 1.0 the training set is a subset of the corpus. + * If that subset is smaller than MAX(d, sizeof(U64)) the partial + * suffix array size used to underflow size_t (issue #4682). The + * builder must reject it cleanly instead. The first few samples are + * tiny so the 0.75 training split stays below the threshold, while + * the total corpus is comfortably large. */ + size_t tinySizes[8] = { 1, 1, 1, 1, 1, 1, 100, 100 }; + ZDICT_cover_params_t tinyParams; + size_t tinyResult; + memset(&tinyParams, 0, sizeof(tinyParams)); + tinyParams.steps = 4; + tinyParams.splitPoint = 0.75; + tinyResult = ZDICT_optimizeTrainFromBuffer_cover( + dictBuffer, optDictSize, CNBuffer, tinySizes, 8, &tinyParams); + if (!ZDICT_isError(tinyResult)) goto _output_error; + } + DISPLAYLEVEL(3, "OK \n"); + ZSTD_freeCCtx(cctx); free(dictBuffer); free(samplesSizes); From 7bc5369666c4a0be663aba78890c3dc6f7f6f102 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 14:05:16 -0800 Subject: [PATCH 27/94] tests: shorten the too large block test Same case in 12 lines instead of 43: the sequences are initialized in their declaration, the literals come from CNBuffer, and CHECK() replaces the error branch. --- tests/fuzzer.c | 43 ++++++------------------------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 2fd92a54ec7..8d1070778dd 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4366,44 +4366,13 @@ static int basicUnitTests(U32 const seed, double compressibility) DISPLAYLEVEL(3, "OK \n"); DISPLAYLEVEL(3, "test%3i : ZSTD_compressSequencesAndLiterals : reject a too large block : ", testNb++); - { - /* A single sequence, followed by an explicit block delimiter, describing - * one block whose regenerated size exceeds ZSTD_BLOCKSIZE_MAX. - * ZSTD_compressSequences() already rejects this in determine_blockSize(), - * so the AndLiterals() variant must reject it too. Otherwise it reports - * success while emitting a block that violates the format. */ - const size_t litLen = 16; - const size_t blockSize = ZSTD_BLOCKSIZE_MAX + 1; - BYTE* const dst = (BYTE*)compressedBuffer; - size_t const dstCapacity = ZSTD_compressBound(blockSize); - BYTE litBuffer[32]; - ZSTD_Sequence seqs[2]; + { /* one block of ZSTD_BLOCKSIZE_MAX + 1 bytes : 16 literals and a match, then the block delimiter */ + ZSTD_Sequence const seqs[2] = { { 1, 16, ZSTD_BLOCKSIZE_MAX + 1 - 16, 0 }, { 0, 0, 0, 0 } }; ZSTD_CCtx* const cctx = ZSTD_createCCtx(); - size_t cSizeTooLarge; - size_t i; - - assert(cctx != NULL); - for (i = 0; i < sizeof(litBuffer); i++) litBuffer[i] = (BYTE)('a' + (i % 3)); - - seqs[0].offset = 1; - seqs[0].litLength = (U32)litLen; - seqs[0].matchLength = (U32)(blockSize - litLen); - seqs[0].rep = 0; - seqs[1].offset = 0; /* block delimiter */ - seqs[1].litLength = 0; - seqs[1].matchLength = 0; - seqs[1].rep = 0; - - CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_blockDelimiters, ZSTD_sf_explicitBlockDelimiters)); - cSizeTooLarge = ZSTD_compressSequencesAndLiterals(cctx, dst, dstCapacity, - seqs, 2, - litBuffer, litLen, sizeof(litBuffer), - blockSize); - if (!ZSTD_isError(cSizeTooLarge)) { - DISPLAY("ZSTD_compressSequencesAndLiterals() should have failed: sequences define a block larger than ZSTD_BLOCKSIZE_MAX\n"); - ZSTD_freeCCtx(cctx); - goto _output_error; - } + CHECK(cctx != NULL); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_blockDelimiters, ZSTD_sf_explicitBlockDelimiters) ); + CHECK( ZSTD_isError(ZSTD_compressSequencesAndLiterals(cctx, compressedBuffer, compressedBufferSize, + seqs, 2, CNBuffer, 16, CNBuffSize, ZSTD_BLOCKSIZE_MAX + 1)) ); ZSTD_freeCCtx(cctx); } DISPLAYLEVEL(3, "OK \n"); From 918ade2db2e2b035bf5d773b316c313889a4fea5 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 14:05:23 -0800 Subject: [PATCH 28/94] zstd.h: state the block size limit of ZSTD_compressSequencesAndLiterals() The caller delimits the blocks, and a block larger than the maximum block size now generates an error. --- lib/zstd.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/zstd.h b/lib/zstd.h index 97fef316fd6..3e7ef796dac 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1699,6 +1699,8 @@ ZSTD_compressSequences(ZSTD_CCtx* cctx, * - @litBufCapacity is the size of the underlying buffer into which literals are written, starting at address @literals. * @litBufCapacity must be at least 8 bytes larger than @litSize. * - @decompressedSize must be correct, and correspond to the sum of all Sequences. Any discrepancy will generate an error. + * - Each block must regenerate at most ZSTD_BLOCKSIZE_MAX bytes, or less with a smaller window or ZSTD_c_maxBlockSize. + * A larger block will generate an error. * @return : final compressed size, or a ZSTD error code. */ ZSTDLIB_STATIC_API size_t From ac5660ce3d806423be92e6498dcb2cf405b0358d Mon Sep 17 00:00:00 2001 From: Jay Howard Date: Tue, 10 Nov 2026 16:23:55 -0600 Subject: [PATCH 29/94] dictBuilder: break COVER sort ties by position, not by address (#4765) * dictBuilder: break COVER sort ties by position, not by address stableSort() is documented to leave each dmer group ordered by position in the input, and COVER_group() depends on it: it counts how many samples a dmer occurs in using a forward-only cursor, so a group whose positions are not ascending has occurrences silently dropped. COVER_strict_cmp() tried to provide that ordering by breaking ties on `lp < rp`. But lp and rp are the addresses of the elements being compared, not the positions they hold. Those coincide only until qsort() performs its first swap; afterwards the tie-break orders by where an element currently sits rather than by what it contains. The result is a comparator that is not a function of the values it compares, with three consequences: - Output is not reproducible. When qsort() compares an element against a temporary (a pivot copy, which is common), one operand is not in the array at all, so the comparison is stack-vs-heap and ASLR decides it. The same binary on the same input produced two different dictionaries in six consecutive runs. - Output depends on the C library. glibc happens to satisfy the invariant; Apple libc and MSVC do not, so they produce different dictionaries from the same input. - An inconsistent comparator breaks quicksort's partitioning assumptions. On MSVC, training on a 16 MB corpus at d=6 takes 465s; with this fix, 8s. Comparing the stored positions instead makes the key (dmer, position). Positions are unique, so no two elements compare equal, the order is total, and every conforming qsort() must produce the same arrangement. Verified: on glibc the output is unchanged (byte-identical across 215 configurations spanning 20 corpora), and on Apple libc and MSVC the output now matches what glibc produces. * dictBuilder: return 0 when COVER tie-break positions are equal COVER_strict_cmp() and COVER_strict_cmp8() break ties between equal dmers by sample position but returned 1 when the two positions were equal, so cmp(a, a) was 1. A qsort() comparator must be consistent, and that includes comparing an element with itself. Suffix positions are unique, so the case is only reachable on a self-comparison and the change does not affect output: dictionaries are byte-identical before and after across 72 corpus/k/d combinations. Add a fuzzer test for the ordering itself. It trains COVER on low-entropy samples, where ties are frequent, and checks the dictionary hash against the one position ordering produces, for both comparator paths. In-place qsort() implementations order ties by partition state under the former address comparison, so the test fails against it (verified on macOS) and passes with position ordering. --- lib/dictBuilder/cover.c | 12 ++++++++---- tests/fuzzer.c | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index e464b22fe56..beeff7a508f 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -310,7 +310,7 @@ static int COVER_cmp8(COVER_ctx_t *ctx, const void *lp, const void *rp) { } /** - * Same as COVER_cmp() except ties are broken by pointer value + * Same as COVER_cmp() except ties are broken by the position each element holds */ #if (ZDICT_QSORT == ZDICT_QSORT_MSVC) || (ZDICT_QSORT == ZDICT_QSORT_APPLE) static int WIN_CDECL COVER_strict_cmp(void* g_coverCtx, const void* lp, const void* rp) { @@ -321,7 +321,9 @@ static int COVER_strict_cmp(const void *lp, const void *rp) { #endif int result = COVER_cmp((COVER_ctx_t*)g_coverCtx, lp, rp); if (result == 0) { - result = lp < rp ? -1 : 1; + U32 const lpos = *(const U32 *)lp; + U32 const rpos = *(const U32 *)rp; + result = (lpos < rpos) ? -1 : (lpos > rpos) ? 1 : 0; } return result; } @@ -337,7 +339,9 @@ static int COVER_strict_cmp8(const void *lp, const void *rp) { #endif int result = COVER_cmp8((COVER_ctx_t*)g_coverCtx, lp, rp); if (result == 0) { - result = lp < rp ? -1 : 1; + U32 const lpos = *(const U32 *)lp; + U32 const rpos = *(const U32 *)rp; + result = (lpos < rpos) ? -1 : (lpos > rpos) ? 1 : 0; } return result; } @@ -703,7 +707,7 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, { /* suffix is a partial suffix array. * It only sorts suffixes by their first parameters.d bytes. - * The sort is stable, so each dmer group is sorted by position in input. + * Ties are broken by position, so each group is sorted by input position. */ U32 i; for (i = 0; i < ctx->suffixSize; ++i) { diff --git a/tests/fuzzer.c b/tests/fuzzer.c index f63eab33a56..4a2f0bac8f8 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3309,6 +3309,45 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK, created dictionary of size %u \n", (unsigned)dictSize); + DISPLAYLEVEL(3, "test%3i : COVER dictBuilder orders equal dmers by position : ", testNb++); + /* COVER sorts its suffix array with qsort(), breaking ties between + * equal dmers by sample position; COVER_group() relies on that order + * to count how many samples contain a dmer. A tie-break that depends on + * the sort's internal state instead (the former address comparison) + * still produces a dictionary, just a different one. Low-entropy + * samples make ties frequent, so train on them and compare against the + * dictionary the position order produces, for both comparator paths. */ + { size_t const dictCapacity = 16 KB; + void* const coverDict = malloc(dictCapacity); + unsigned const dValues[2] = { 8, 16 }; /* COVER_cmp8() and COVER_cmp() */ + XXH64_hash_t const expectedHash[2] = { 0x77EB0C1D595FF7A3ULL, 0xE02EC0FD3A5BBF6DULL }; + unsigned dIdx; + if (coverDict==NULL) goto _output_error; + RDG_genBuffer(decodedBuffer, totalSampleSize, 0.5, 0.05, 1); /* low-entropy: many repeated dmers */ + { U32 u; for (u=0; u> 32), (unsigned)hash, + (unsigned)(expectedHash[dIdx] >> 32), (unsigned)expectedHash[dIdx]); + goto _output_error; + } + } + free(coverDict); + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : check dictID : ", testNb++); dictID = ZDICT_getDictID(dictBuffer, dictSize); if (dictID==0) goto _output_error; From 35a056ae0be114c5505a3521053ee3342062603b Mon Sep 17 00:00:00 2001 From: kevinjzhang <47681004+kevinjzhang@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:38:58 -0800 Subject: [PATCH 30/94] Revert "Fix size_t underflow in COVER_ctx_init for small training sets (#4690)" (#4919) This reverts commit 39ec35413e2626c766c61539fc528a1380adacb8. --- lib/dictBuilder/cover.c | 15 ++------------- tests/fuzzer.c | 19 ------------------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index beeff7a508f..30a2c65dee0 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -640,25 +640,14 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, const unsigned nbTestSamples = splitPoint < 1.0 ? nbSamples - nbTrainSamples : nbSamples; const size_t trainingSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes, nbTrainSamples) : totalSamplesSize; const size_t testSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes + nbTrainSamples, nbTestSamples) : totalSamplesSize; - const size_t readLength = MAX(d, sizeof(U64); ctx->displayLevel = displayLevel; /* Checks */ - if (totalSamplesSize < readLength) || + if (totalSamplesSize < MAX(d, sizeof(U64)) || totalSamplesSize >= (size_t)COVER_MAX_SAMPLES_SIZE) { DISPLAYLEVEL(1, "Total samples size is too large (%u MB), maximum size is %u MB\n", (unsigned)(totalSamplesSize>>20), (COVER_MAX_SAMPLES_SIZE >> 20)); return ERROR(srcSize_wrong); } - /* The training set must be large enough for the partial suffix array, whose - * size is `trainingSamplesSize - MAX(d, sizeof(U64)) + 1`. When splitPoint - * < 1.0 the training set is a subset of the corpus, so it can be smaller - * than the total even after the check above. Guard against the size_t - * subtraction underflowing into a huge allocation. */ - if (trainingSamplesSize < MAX(d, sizeof(U64))) { - DISPLAYLEVEL(1, "Training samples size is too small (%u), minimum size is %u\n", - (unsigned)trainingSamplesSize, (unsigned)MAX(d, sizeof(U64))); - return ERROR(srcSize_wrong); - } /* Check if there are at least 5 training samples */ if (nbTrainSamples < 5) { DISPLAYLEVEL(1, "Total number of training samples is %u and is invalid.", nbTrainSamples); @@ -681,7 +670,7 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, ctx->nbTrainSamples = nbTrainSamples; ctx->nbTestSamples = nbTestSamples; /* Partial suffix array */ - ctx->suffixSize = trainingSamplesSize - readLength + 1; + ctx->suffixSize = trainingSamplesSize - MAX(d, sizeof(U64)) + 1; ctx->suffix = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); /* Maps index to the dmerID */ ctx->dmerAt = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 4a2f0bac8f8..346ef5c82ef 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4260,25 +4260,6 @@ static int basicUnitTests(U32 const seed, double compressibility) if (dictID==0) goto _output_error; DISPLAYLEVEL(3, "OK : %u \n", (unsigned)dictID); - DISPLAYLEVEL(3, "test%3i : ZDICT_optimizeTrainFromBuffer_cover with tiny training set : ", testNb++); - { /* With splitPoint < 1.0 the training set is a subset of the corpus. - * If that subset is smaller than MAX(d, sizeof(U64)) the partial - * suffix array size used to underflow size_t (issue #4682). The - * builder must reject it cleanly instead. The first few samples are - * tiny so the 0.75 training split stays below the threshold, while - * the total corpus is comfortably large. */ - size_t tinySizes[8] = { 1, 1, 1, 1, 1, 1, 100, 100 }; - ZDICT_cover_params_t tinyParams; - size_t tinyResult; - memset(&tinyParams, 0, sizeof(tinyParams)); - tinyParams.steps = 4; - tinyParams.splitPoint = 0.75; - tinyResult = ZDICT_optimizeTrainFromBuffer_cover( - dictBuffer, optDictSize, CNBuffer, tinySizes, 8, &tinyParams); - if (!ZDICT_isError(tinyResult)) goto _output_error; - } - DISPLAYLEVEL(3, "OK \n"); - ZSTD_freeCCtx(cctx); free(dictBuffer); free(samplesSizes); From 01136999e9fc788b7498246c02c90cbfb0267639 Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:02:13 +0530 Subject: [PATCH 31/94] Fix size_t underflow in COVER_ctx_init for small training sets When splitPoint < 1.0, the COVER dictionary builder trains on a subset of the corpus, so trainingSamplesSize can be smaller than the total even after the existing totalSamplesSize check. The partial suffix array size is computed as `trainingSamplesSize - MAX(d, sizeof(U64)) + 1`, which underflows size_t when the training set is too small, producing an enormous allocation request instead of a clean error. Guard against an undersized training set before the subtraction and return srcSize_wrong, and add a regression test in fuzzer.c. Fixes #4682. --- lib/dictBuilder/cover.c | 10 ++++++++++ tests/fuzzer.c | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index 30a2c65dee0..89355dad673 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -648,6 +648,16 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, (unsigned)(totalSamplesSize>>20), (COVER_MAX_SAMPLES_SIZE >> 20)); return ERROR(srcSize_wrong); } + /* The training set must be large enough for the partial suffix array, whose + * size is `trainingSamplesSize - MAX(d, sizeof(U64)) + 1`. When splitPoint + * < 1.0 the training set is a subset of the corpus, so it can be smaller + * than the total even after the check above. Guard against the size_t + * subtraction underflowing into a huge allocation. */ + if (trainingSamplesSize < MAX(d, sizeof(U64))) { + DISPLAYLEVEL(1, "Training samples size is too small (%u), minimum size is %u\n", + (unsigned)trainingSamplesSize, (unsigned)MAX(d, sizeof(U64))); + return ERROR(srcSize_wrong); + } /* Check if there are at least 5 training samples */ if (nbTrainSamples < 5) { DISPLAYLEVEL(1, "Total number of training samples is %u and is invalid.", nbTrainSamples); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 346ef5c82ef..4a2f0bac8f8 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -4260,6 +4260,25 @@ static int basicUnitTests(U32 const seed, double compressibility) if (dictID==0) goto _output_error; DISPLAYLEVEL(3, "OK : %u \n", (unsigned)dictID); + DISPLAYLEVEL(3, "test%3i : ZDICT_optimizeTrainFromBuffer_cover with tiny training set : ", testNb++); + { /* With splitPoint < 1.0 the training set is a subset of the corpus. + * If that subset is smaller than MAX(d, sizeof(U64)) the partial + * suffix array size used to underflow size_t (issue #4682). The + * builder must reject it cleanly instead. The first few samples are + * tiny so the 0.75 training split stays below the threshold, while + * the total corpus is comfortably large. */ + size_t tinySizes[8] = { 1, 1, 1, 1, 1, 1, 100, 100 }; + ZDICT_cover_params_t tinyParams; + size_t tinyResult; + memset(&tinyParams, 0, sizeof(tinyParams)); + tinyParams.steps = 4; + tinyParams.splitPoint = 0.75; + tinyResult = ZDICT_optimizeTrainFromBuffer_cover( + dictBuffer, optDictSize, CNBuffer, tinySizes, 8, &tinyParams); + if (!ZDICT_isError(tinyResult)) goto _output_error; + } + DISPLAYLEVEL(3, "OK \n"); + ZSTD_freeCCtx(cctx); free(dictBuffer); free(samplesSizes); From 8fcf03f955d7773081b0ab165da77cad077591c2 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Tue, 10 Nov 2026 13:05:26 -0800 Subject: [PATCH 32/94] Factor out COVER read length calculation --- lib/dictBuilder/cover.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index 89355dad673..83debc0da9b 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -640,9 +640,10 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, const unsigned nbTestSamples = splitPoint < 1.0 ? nbSamples - nbTrainSamples : nbSamples; const size_t trainingSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes, nbTrainSamples) : totalSamplesSize; const size_t testSamplesSize = splitPoint < 1.0 ? COVER_sum(samplesSizes + nbTrainSamples, nbTestSamples) : totalSamplesSize; + const size_t readLength = MAX(d, sizeof(U64)); ctx->displayLevel = displayLevel; /* Checks */ - if (totalSamplesSize < MAX(d, sizeof(U64)) || + if (totalSamplesSize < readLength || totalSamplesSize >= (size_t)COVER_MAX_SAMPLES_SIZE) { DISPLAYLEVEL(1, "Total samples size is too large (%u MB), maximum size is %u MB\n", (unsigned)(totalSamplesSize>>20), (COVER_MAX_SAMPLES_SIZE >> 20)); @@ -680,7 +681,7 @@ static size_t COVER_ctx_init(COVER_ctx_t *ctx, const void *samplesBuffer, ctx->nbTrainSamples = nbTrainSamples; ctx->nbTestSamples = nbTestSamples; /* Partial suffix array */ - ctx->suffixSize = trainingSamplesSize - MAX(d, sizeof(U64)) + 1; + ctx->suffixSize = trainingSamplesSize - readLength + 1; ctx->suffix = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); /* Maps index to the dmerID */ ctx->dmerAt = (U32 *)malloc(ctx->suffixSize * sizeof(U32)); From 4dde8bf8c4461ca8fbfb734992b09fac8da43139 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Tue, 10 Nov 2026 09:48:43 -0800 Subject: [PATCH 33/94] Fix legacy dictionary magic out-of-bounds reads Treat dictionaries shorter than four bytes as raw content in the v0.5 and v0.6 decoders. Add tiny-dictionary and truncated-header regression coverage across all supported frame formats and both decompression APIs. --- lib/legacy/zstd_v05.c | 3 +- lib/legacy/zstd_v06.c | 3 +- tests/legacy.c | 138 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/lib/legacy/zstd_v05.c b/lib/legacy/zstd_v05.c index ee180487e94..264a54a7503 100644 --- a/lib/legacy/zstd_v05.c +++ b/lib/legacy/zstd_v05.c @@ -3662,8 +3662,7 @@ static size_t ZSTDv05_loadEntropy(ZSTDv05_DCtx* dctx, const void* dict, size_t d static size_t ZSTDv05_decompress_insertDictionary(ZSTDv05_DCtx* dctx, const void* dict, size_t dictSize) { size_t eSize; - U32 magic = MEM_readLE32(dict); - if (magic != ZSTDv05_DICT_MAGIC) { + if (dictSize < 4 || MEM_readLE32(dict) != ZSTDv05_DICT_MAGIC) { /* pure content mode */ ZSTDv05_refDictContent(dctx, dict, dictSize); return 0; diff --git a/lib/legacy/zstd_v06.c b/lib/legacy/zstd_v06.c index fb8c14df914..a4f8b9dc7d7 100644 --- a/lib/legacy/zstd_v06.c +++ b/lib/legacy/zstd_v06.c @@ -3802,8 +3802,7 @@ static size_t ZSTDv06_loadEntropy(ZSTDv06_DCtx* dctx, const void* dict, size_t d static size_t ZSTDv06_decompress_insertDictionary(ZSTDv06_DCtx* dctx, const void* dict, size_t dictSize) { size_t eSize; - U32 const magic = MEM_readLE32(dict); - if (magic != ZSTDv06_DICT_MAGIC) { + if (dictSize < 4 || MEM_readLE32(dict) != ZSTDv06_DICT_MAGIC) { /* pure content mode */ ZSTDv06_refDictContent(dctx, dict, dictSize); return 0; diff --git a/tests/legacy.c b/tests/legacy.c index 18a590f1d51..d2ae8ecd1f2 100644 --- a/tests/legacy.c +++ b/tests/legacy.c @@ -256,6 +256,143 @@ static int testFrameDecoding(const char* compressed, size_t compressedSize, return 0; } +static int testDictionaryAPI(unsigned version, + const char* compressed, size_t compressedSize, + const void* dict, size_t dictSize) +{ + size_t const expectedSize = strlen(BLOCK); + char* const decoded = malloc(expectedSize); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + ZSTD_inBuffer input = { compressed, compressedSize, 0 }; + ZSTD_outBuffer output = { decoded, expectedSize, 0 }; + size_t ret; + int result = 1; + + if (decoded == NULL || dctx == NULL) { + DISPLAY("ERROR: Not enough memory!\n"); + goto cleanup; + } + + ret = ZSTD_decompress_usingDict(dctx, decoded, expectedSize, + compressed, compressedSize, dict, dictSize); + if (ZSTD_isError(ret)) { + DISPLAY("ERROR: ZSTD_decompress_usingDict: %s\n", ZSTD_getErrorName(ret)); + goto cleanup; + } + if (ret != expectedSize || memcmp(decoded, BLOCK, expectedSize) != 0) { + DISPLAY("ERROR: Wrong decoded output with dictionary\n"); + goto cleanup; + } + + /* Formats older than v0.4 have no streaming decoder. */ + if (version < OLDEST_STREAMABLE_VERSION) { + result = 0; + goto cleanup; + } + + ret = ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters); + if (ZSTD_isError(ret)) goto error; + /* Keep the exact-sized allocation visible to ASan. */ + ret = ZSTD_DCtx_loadDictionary_byReference(dctx, dict, dictSize); + if (ZSTD_isError(ret)) goto error; + do { + size_t const oldInputPos = input.pos; + size_t const oldOutputPos = output.pos; + ret = ZSTD_decompressStream(dctx, &output, &input); + if (ZSTD_isError(ret)) goto error; + if (ret != 0 && input.pos == oldInputPos && output.pos == oldOutputPos) { + DISPLAY("ERROR: Dictionary stream made no progress\n"); + goto cleanup; + } + } while (ret != 0); + if (input.pos != input.size || output.pos != expectedSize + || memcmp(decoded, BLOCK, expectedSize) != 0) { + DISPLAY("ERROR: Wrong streaming output with dictionary\n"); + goto cleanup; + } + result = 0; + goto cleanup; + +error: + DISPLAY("ERROR: Dictionary streaming API: %s\n", ZSTD_getErrorName(ret)); +cleanup: + ZSTD_freeDCtx(dctx); + free(decoded); + return result; +} + +static int testLegacyDictionaries(void) +{ + struct { + unsigned version; + const char* data; + size_t size; + } const frames[] = { +#if (ZSTD_LEGACY_SUPPORT <= 1) + { 1, FRAME_V01, FRAME_V01_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 2) + { 2, FRAME_V02, FRAME_V02_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 3) + { 3, FRAME_V03, FRAME_V03_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 4) + { 4, FRAME_V04, FRAME_V04_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 5) + { 5, FRAME_V05, FRAME_V05_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 6) + { 6, FRAME_V06, FRAME_V06_SIZE }, +#endif +#if (ZSTD_LEGACY_SUPPORT <= 7) + { 7, FRAME_V07, FRAME_V07_SIZE }, +#endif + { 8, FRAME_V08, FRAME_V08_SIZE } + }; + size_t f; + + for (f = 0; f < sizeof(frames) / sizeof(frames[0]); ++f) { + unsigned pattern; + for (pattern = 0; pattern < 2; ++pattern) { + unsigned char dictContent[8] = { 0x37, 0xA4, 0x30, 0xEC, 0, 0, 0, 0 }; + size_t maxSize = sizeof(dictContent); + size_t dictSize; + if (pattern == 0) { + memset(dictContent, 'A', sizeof(dictContent)); + } else { + /* v0.5 and v0.6 have a four-byte magic, while v0.7 + * also has a four-byte dictionary ID. Every truncated + * header must be treated as raw dictionary content. */ + if (frames[f].version < 5 || frames[f].version > 7) continue; + dictContent[0] = (unsigned char)(0x30 + frames[f].version); + maxSize = frames[f].version == 7 ? 7 : 3; + } + for (dictSize = 0; dictSize <= maxSize; ++dictSize) { + void* const dict = malloc(dictSize == 0 ? 1 : dictSize); + int failed; + if (dict == NULL) { + DISPLAY("ERROR: Not enough memory!\n"); + return 1; + } + memcpy(dict, dictContent, dictSize); + failed = testDictionaryAPI(frames[f].version, + frames[f].data, frames[f].size, + dict, dictSize); + free(dict); + if (failed) { + DISPLAY("ERROR: v0.%u dictionary of size %u, pattern %u\n", + frames[f].version, (unsigned)dictSize, pattern); + return 1; + } + } + } + } + DISPLAY("Legacy dictionary API OK\n"); + return 0; +} + int main(void) { size_t const blockSize = strlen(BLOCK); @@ -298,6 +435,7 @@ int main(void) * are what the streaming subset must decode to */ if (testStreamingAPI(streamable, streamableSize, expected, blockSize * nbStreamFrames)) break; + if (testLegacyDictionaries()) break; result = 0; } while (0); From 3ea103de205d6c5600625ecfa11cdb5f10dfe0f0 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Tue, 10 Nov 2026 15:51:41 -0800 Subject: [PATCH 34/94] Fix overlapping copies in CLI read-buffer coalescing AIO_ReadPool_consumeBytes() can advance srcBuffer into coalesceBuffer. A later refill moves the remaining bytes back to the beginning of the same buffer, so memcpy() is undefined when those ranges overlap. Use memmove() for this compaction. A valid 17-byte stream consisting of an empty skippable frame and an empty zstd frame reproduces a 9-byte copy from coalesceBuffer + 8 under ASan. Add a CLI regression covering test and decompression modes with async I/O enabled and disabled. Require successful decoding and empty output so sanitizer failures cannot pass as expected input errors. --- programs/fileio_asyncio.c | 3 ++- .../decompression/skippable-overlap.sh | 17 +++++++++++++++++ .../skippable-overlap.sh.stderr.exact | 0 .../skippable-overlap.sh.stdout.exact | 0 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100755 tests/cli-tests/decompression/skippable-overlap.sh create mode 100644 tests/cli-tests/decompression/skippable-overlap.sh.stderr.exact create mode 100644 tests/cli-tests/decompression/skippable-overlap.sh.stdout.exact diff --git a/programs/fileio_asyncio.c b/programs/fileio_asyncio.c index 42a47201656..c9d8ea1718e 100644 --- a/programs/fileio_asyncio.c +++ b/programs/fileio_asyncio.c @@ -614,7 +614,8 @@ size_t AIO_ReadPool_fillBuffer(ReadPoolCtx_t* ctx, size_t n) { * and coalesce the remaining bytes with the next job's buffer */ if (ctx->srcBufferLoaded > 0) { useCoalesce = 1; - memcpy(ctx->coalesceBuffer, ctx->srcBuffer, ctx->srcBufferLoaded); + /* Remaining bytes may already be in coalesceBuffer. */ + memmove(ctx->coalesceBuffer, ctx->srcBuffer, ctx->srcBufferLoaded); ctx->srcBuffer = ctx->coalesceBuffer; } diff --git a/tests/cli-tests/decompression/skippable-overlap.sh b/tests/cli-tests/decompression/skippable-overlap.sh new file mode 100755 index 00000000000..d72d26cc599 --- /dev/null +++ b/tests/cli-tests/decompression/skippable-overlap.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -eu + +# An 8-byte empty skippable frame followed by a 9-byte empty zstd frame. +# The initial 18-byte header refill moves this 17-byte stream into the +# coalesce buffer. After consuming the skippable frame, the next refill +# moves 9 bytes from offset 8 back to offset 0. Run under ASan to detect +# an overlapping memcpy. +printf '\120\052\115\030\000\000\000\000\050\265\057\375\040\000\001\000\000' > skippable-overlap.zst + +# This is a valid stream and must decode successfully to empty output. +for io in --asyncio --no-asyncio; do + for operation in -t -dc; do + zstd -q "$io" "$operation" skippable-overlap.zst + done +done diff --git a/tests/cli-tests/decompression/skippable-overlap.sh.stderr.exact b/tests/cli-tests/decompression/skippable-overlap.sh.stderr.exact new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/cli-tests/decompression/skippable-overlap.sh.stdout.exact b/tests/cli-tests/decompression/skippable-overlap.sh.stdout.exact new file mode 100644 index 00000000000..e69de29bb2d From 974238ca9e5e05fc0241aa09e10b4837d77cf78e Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Tue, 10 Nov 2026 11:12:03 -0800 Subject: [PATCH 35/94] Fix block splitter partition bounds Valid external sequence streams can exhaust the recursive block splitter's budget even within the 128 KiB block-size limit. The budget was checked only on function entry, so a left-child recursion could fill the partition array before its ancestors wrote their own split positions. The terminating block boundary also had no dedicated slot. These writes could silently overwrite adjacent compression-context state. Recheck the split budget after the left recursion, reserve one extra partition entry for the terminating boundary, and assert the final split count. Update the comment that incorrectly described the limit as unreachable. Add a deterministic regression using 38,400 valid external sequences with sequence validation enabled. It verifies the 196-split limit, the in-bounds terminator, and a successful round trip. The unmodified baseline emitted 200 blocks; the fixed implementation emits 197. --- lib/compress/zstd_compress.c | 9 ++- lib/compress/zstd_compress_internal.h | 2 +- tests/fuzzer.c | 79 +++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index c51fd1853ff..712c5eca346 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -4232,8 +4232,8 @@ typedef struct { * In practice, recursion depth usually doesn't go beyond 4. * * Furthermore, the number of splits is capped by ZSTD_MAX_NB_BLOCK_SPLITS. - * At ZSTD_MAX_NB_BLOCK_SPLITS == 196 with the current existing blockSize - * maximum of 128 KB, this value is actually impossible to reach. + * A 128 KiB block containing many 3-byte matches can have enough sequences + * for more splits than this limit allows. */ static void ZSTD_deriveBlockSplitsHelper(seqStoreSplits* splits, size_t startIdx, size_t endIdx, @@ -4267,6 +4267,8 @@ ZSTD_deriveBlockSplitsHelper(seqStoreSplits* splits, size_t startIdx, size_t end if (estimatedFirstHalfSize + estimatedSecondHalfSize < estimatedOriginalSize) { DEBUGLOG(5, "split decided at seqNb:%zu", midIdx); ZSTD_deriveBlockSplitsHelper(splits, startIdx, midIdx, zc, origSeqStore); + if (splits->idx >= ZSTD_MAX_NB_BLOCK_SPLITS) + return; splits->splitLocations[splits->idx] = (U32)midIdx; splits->idx++; ZSTD_deriveBlockSplitsHelper(splits, midIdx, endIdx, zc, origSeqStore); @@ -4289,6 +4291,7 @@ static size_t ZSTD_deriveBlockSplits(ZSTD_CCtx* zc, U32 partitions[], U32 nbSeq) return 0; } ZSTD_deriveBlockSplitsHelper(&splits, 0, nbSeq, zc, &zc->seqStore); + assert(splits.idx <= ZSTD_MAX_NB_BLOCK_SPLITS); splits.splitLocations[splits.idx] = nbSeq; DEBUGLOG(5, "ZSTD_deriveBlockSplits: final nb partitions: %zu", splits.idx+1); return splits.idx; @@ -4310,7 +4313,7 @@ ZSTD_compressBlock_splitBlock_internal(ZSTD_CCtx* zc, BYTE* op = (BYTE*)dst; size_t i = 0; size_t srcBytesTotal = 0; - U32* const partitions = zc->blockSplitCtx.partitions; /* size == ZSTD_MAX_NB_BLOCK_SPLITS */ + U32* const partitions = zc->blockSplitCtx.partitions; /* includes the final block boundary */ SeqStore_t* const nextSeqStore = &zc->blockSplitCtx.nextSeqStore; SeqStore_t* const currSeqStore = &zc->blockSplitCtx.currSeqStore; size_t const numSplits = ZSTD_deriveBlockSplits(zc, partitions, nbSeq); diff --git a/lib/compress/zstd_compress_internal.h b/lib/compress/zstd_compress_internal.h index c15a94da3b5..afebbdae33d 100644 --- a/lib/compress/zstd_compress_internal.h +++ b/lib/compress/zstd_compress_internal.h @@ -465,7 +465,7 @@ typedef struct { SeqStore_t currSeqStore; SeqStore_t nextSeqStore; - U32 partitions[ZSTD_MAX_NB_BLOCK_SPLITS]; + U32 partitions[ZSTD_MAX_NB_BLOCK_SPLITS + 1]; /* splits, followed by the end of the block */ ZSTD_entropyCTablesMetadata_t entropyMetadata; } ZSTD_blockSplitCtx; diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 4a2f0bac8f8..06e9830bfa4 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1040,6 +1040,84 @@ static unsigned test_convertSequences_noRepcodes(unsigned seed, unsigned testNb) return testNb; } +static U32 FUZ_blockSplitHash(U32 x) +{ + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + return x ^ (x >> 16); +} + +/* The input is all zeroes, so all these offsets are valid matches. + * Groups of 150 sequences have distinct offset-code distributions, making + * enough dyadic splits beneficial to exhaust the block splitter's budget. */ +static size_t FUZ_blockSplitSequenceProducer(void* state, + ZSTD_Sequence* out, size_t capacity, + const void* src, size_t srcSize, const void* dict, size_t dictSize, + int compressionLevel, size_t windowSize) +{ + size_t i; + (void)state; (void)src; (void)dict; (void)dictSize; + (void)compressionLevel; (void)windowSize; + CHECK_EQ(srcSize, 128000); + CHECK(capacity >= 38401); + for (i = 0; i < 38400; ++i) { + U32 const code = 3 + FUZ_blockSplitHash((U32)(i / 150)) % 10; + out[i].offset = (1U << code) + (U32)(i % 4); + out[i].litLength = i == 0 ? 12800 : 0; + out[i].matchLength = 3; + out[i].rep = 0; + } + memset(out + i, 0, sizeof(*out)); + return i + 1; +} + +static unsigned test_blockSplitLimit(unsigned testNb) +{ + size_t const srcSize = 128000; + size_t const dstCapacity = ZSTD_compressBound(srcSize); + BYTE* const src = (BYTE*)calloc(1, srcSize); + BYTE* const dst = (BYTE*)malloc(dstCapacity); + BYTE* const decoded = (BYTE*)malloc(srcSize); + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + ZSTD_FrameHeader header; + size_t cSize, pos, nbBlocks = 0; + + DISPLAYLEVEL(3, "test%3u : external sequences exhaust the block split budget : ", testNb++); + CHECK(src != NULL && dst != NULL && decoded != NULL && cctx != NULL); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 19)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_minMatch, 3)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_splitAfterSequences, ZSTD_ps_enable)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_validateSequences, 1)); + ZSTD_registerSequenceProducer(cctx, NULL, FUZ_blockSplitSequenceProducer); + cSize = ZSTD_compress2(cctx, dst, dstCapacity, src, srcSize); + CHECK_Z(cSize); + CHECK_EQ(ZSTD_getFrameHeader(&header, dst, cSize), 0); + pos = header.headerSize; + for (;;) { + U32 blockHeader; + CHECK(pos + ZSTD_blockHeaderSize <= cSize); + blockHeader = MEM_readLE24(dst + pos); + pos += ZSTD_blockHeaderSize; + pos += ((blockHeader >> 1) & 3) == bt_rle ? 1 : blockHeader >> 3; + CHECK(pos <= cSize); + ++nbBlocks; + if (blockHeader & 1) break; + } + CHECK_EQ(pos, cSize); + CHECK_EQ(nbBlocks, ZSTD_MAX_NB_BLOCK_SPLITS + 1); + CHECK_EQ(cctx->blockSplitCtx.partitions[ZSTD_MAX_NB_BLOCK_SPLITS], 38400); + CHECK_EQ(ZSTD_decompress(decoded, srcSize, dst, cSize), srcSize); + CHECK_EQ(memcmp(src, decoded, srcSize), 0); + ZSTD_freeCCtx(cctx); + free(decoded); + free(dst); + free(src); + DISPLAYLEVEL(3, "OK \n"); + return testNb; +} + static unsigned test_get1BlockSummary(unsigned testNb) { static const ZSTD_Sequence nseqs[] = { @@ -4729,6 +4807,7 @@ static int basicUnitTests(U32 const seed, double compressibility) testNb = test_convertSequences_noRepcodes(seed, testNb); testNb = test_get1BlockSummary(testNb); + testNb = test_blockSplitLimit(testNb); DISPLAYLEVEL(3, "test%3i : ZSTD_compressSequencesAndLiterals : ", testNb++); { From 719e5287591c2c514c05a04e3c8b0810ce50f182 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Tue, 10 Nov 2026 10:12:19 -0800 Subject: [PATCH 36/94] compress: validate repcodes against retained dictionary content Full-format dictionaries may contain repeat offsets into content discarded by the short-cache CDict limit or the general index limit. Revalidate all three repcodes after loading the retained suffix and enforce the same bound before attaching a CDict, preventing wrapped dictMatchState indices and wild reads. Add regression coverage for 20 MiB dictionaries, every repcode slot, fast/dfast levels, deferred and streaming dictionary loading, the short-cache boundary, and valid non-truncating controls. --- lib/compress/zstd_compress.c | 88 ++++++++-- lib/compress/zstd_double_fast.c | 4 +- lib/compress/zstd_fast.c | 4 +- lib/compress/zstd_lazy.c | 4 +- tests/fuzzer.c | 286 ++++++++++++++++++++++++++++++++ 5 files changed, 363 insertions(+), 23 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 99d4e71493a..09ca36f1922 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -1264,6 +1264,13 @@ static int ZSTD_dedicatedDictSearch_isSupported( const ZSTD_compressionParameters* cParams); static void ZSTD_dedicatedDictSearch_revertCParams( ZSTD_compressionParameters* cParams); +static size_t ZSTD_createCDict_advanced2_internal( + ZSTD_CDict** cdictPtr, + const void* dict, size_t dictSize, + ZSTD_dictLoadMethod_e dictLoadMethod, + ZSTD_dictContentType_e dictContentType, + const ZSTD_CCtx_params* originalCctxParams, + ZSTD_customMem customMem); /** * Initializes the local dictionary using requested parameters. @@ -1289,14 +1296,14 @@ static size_t ZSTD_initLocalDict(ZSTD_CCtx* cctx) assert(cctx->cdict == NULL); assert(cctx->prefixDict.dict == NULL); - dl->cdict = ZSTD_createCDict_advanced2( + FORWARD_IF_ERROR(ZSTD_createCDict_advanced2_internal( + &dl->cdict, dl->dict, dl->dictSize, ZSTD_dlm_byRef, dl->dictContentType, &cctx->requestedParams, - cctx->customMem); - RETURN_ERROR_IF(!dl->cdict, memory_allocation, "ZSTD_createCDict_advanced failed"); + cctx->customMem), ""); cctx->cdict = dl->cdict; return 0; } @@ -2363,6 +2370,20 @@ static int ZSTD_shouldAttachDict(const ZSTD_CDict* cdict, * handled in _enforceMaxDist */ } +/* Check against the addressable content, which may be smaller than the full + * dictionary when only a suffix is loaded. */ +static size_t ZSTD_checkDictionaryRepCodes(const U32 rep[ZSTD_REP_NUM], size_t dictContentSize) +{ + U32 u; + for (u = 0; u < ZSTD_REP_NUM; ++u) { + RETURN_ERROR_IF(rep[u] == 0 || rep[u] > dictContentSize, + dictionary_corrupted, + "repcode %u exceeds dictionary content (%zu bytes)", + rep[u], dictContentSize); + } + return 0; +} + static size_t ZSTD_resetCCtx_byAttachingCDict(ZSTD_CCtx* cctx, const ZSTD_CDict* cdict, @@ -2402,6 +2423,9 @@ ZSTD_resetCCtx_byAttachingCDict(ZSTD_CCtx* cctx, /* don't even attach dictionaries with no contents */ DEBUGLOG(4, "skipping attaching empty dictionary"); } else { + /* Enforce the dictMatchState block compressors' repcode bounds + * before allowing them to search this dictionary. */ + FORWARD_IF_ERROR(ZSTD_checkDictionaryRepCodes(cdict->cBlockState.rep, cdictLen), ""); DEBUGLOG(4, "attaching dictionary into context"); cctx->blockState.matchState.dictMatchState = &cdict->matchState; @@ -5184,12 +5208,9 @@ size_t ZSTD_loadCEntropy(ZSTD_compressedBlockState_t* bs, void* workspace, /* All offset values <= dictContentSize + 128 KB must be representable for a valid table */ bs->entropy.fse.offcode_repeatMode = ZSTD_dictNCountRepeat(offcodeNCount, offcodeMaxValue, MIN(offcodeMax, MaxOff)); - /* All repCodes must be <= dictContentSize and != 0 */ - { U32 u; - for (u=0; u<3; u++) { - RETURN_ERROR_IF(bs->rep[u] == 0, dictionary_corrupted, ""); - RETURN_ERROR_IF(bs->rep[u] > dictContentSize, dictionary_corrupted, ""); - } } } + /* The format permits nonzero repcodes up to the full content size. */ + FORWARD_IF_ERROR(ZSTD_checkDictionaryRepCodes(bs->rep, dictContentSize), ""); + } return (size_t)(dictPtr - (const BYTE*)dict); } @@ -5230,6 +5251,15 @@ static size_t ZSTD_loadZstdDictionary(ZSTD_compressedBlockState_t* bs, FORWARD_IF_ERROR(ZSTD_loadDictionaryContent( ms, NULL, ws, params, dictPtr, dictContentSize, dtlm, tfp), ""); } + if (tfp == ZSTD_tfp_forCDict) { + /* The matchfinder may retain only a suffix of a large dictionary. + * In particular, short-cache CDicts retain at most (1 << 24) - 2 + * bytes. Recheck the repcodes against the actual window so that + * dictMatchState searches cannot underflow their match indices. */ + U32 const loadedDictSize = (U32)(ms->window.nextSrc - ms->window.base) - ms->window.lowLimit; + assert((U32)(ms->window.nextSrc - ms->window.base) >= ms->window.lowLimit); + FORWARD_IF_ERROR(ZSTD_checkDictionaryRepCodes(bs->rep, loadedDictSize), ""); + } return dictID; } @@ -5702,7 +5732,10 @@ ZSTD_CDict* ZSTD_createCDict_advanced(const void* dictBuffer, size_t dictSize, &cctxParams, customMem); } -ZSTD_CDict* ZSTD_createCDict_advanced2( +/* Preserve initialization errors for internal callers. The public constructors + * retain their pointer-or-NULL API. On failure, *cdictPtr remains NULL. */ +static size_t ZSTD_createCDict_advanced2_internal( + ZSTD_CDict** cdictPtr, const void* dict, size_t dictSize, ZSTD_dictLoadMethod_e dictLoadMethod, ZSTD_dictContentType_e dictContentType, @@ -5713,8 +5746,10 @@ ZSTD_CDict* ZSTD_createCDict_advanced2( ZSTD_compressionParameters cParams; ZSTD_CDict* cdict; + *cdictPtr = NULL; DEBUGLOG(3, "ZSTD_createCDict_advanced2, dictSize=%u, mode=%u", (unsigned)dictSize, (unsigned)dictContentType); - if (!customMem.customAlloc ^ !customMem.customFree) return NULL; + RETURN_ERROR_IF((!customMem.customAlloc) ^ (!customMem.customFree), + parameter_combination_unsupported, "Invalid custom allocator"); if (cctxParams.enableDedicatedDictSearch) { cParams = ZSTD_dedicatedDictSearch_getCParams( @@ -5741,14 +5776,33 @@ ZSTD_CDict* ZSTD_createCDict_advanced2( cctxParams.useRowMatchFinder, cctxParams.enableDedicatedDictSearch, customMem); - if (!cdict || ZSTD_isError( ZSTD_initCDict_internal(cdict, - dict, dictSize, - dictLoadMethod, dictContentType, - cctxParams) )) { - ZSTD_freeCDict(cdict); - return NULL; + RETURN_ERROR_IF(!cdict, memory_allocation, "Could not allocate CDict"); + { size_t const err = ZSTD_initCDict_internal(cdict, + dict, dictSize, + dictLoadMethod, dictContentType, + cctxParams); + if (ZSTD_isError(err)) { + ZSTD_freeCDict(cdict); + return err; + } } + *cdictPtr = cdict; + return 0; +} + +ZSTD_CDict* ZSTD_createCDict_advanced2( + const void* dict, size_t dictSize, + ZSTD_dictLoadMethod_e dictLoadMethod, + ZSTD_dictContentType_e dictContentType, + const ZSTD_CCtx_params* originalCctxParams, + ZSTD_customMem customMem) +{ + ZSTD_CDict* cdict; + size_t const err = ZSTD_createCDict_advanced2_internal( + &cdict, dict, dictSize, dictLoadMethod, dictContentType, + originalCctxParams, customMem); + if (ZSTD_isError(err)) return NULL; return cdict; } diff --git a/lib/compress/zstd_double_fast.c b/lib/compress/zstd_double_fast.c index 1a266e7d955..568df6f7204 100644 --- a/lib/compress/zstd_double_fast.c +++ b/lib/compress/zstd_double_fast.c @@ -375,8 +375,8 @@ size_t ZSTD_compressBlock_doubleFast_dictMatchState_generic( /* init */ ip += (dictAndPrefixLength == 0); - /* dictMatchState repCode checks don't currently handle repCode == 0 - * disabling. */ + /* Dictionary repcodes are validated when the CDict is attached. + * dictMatchState repCode checks don't handle repCode == 0 disabling. */ assert(offset_1 <= dictAndPrefixLength); assert(offset_2 <= dictAndPrefixLength); diff --git a/lib/compress/zstd_fast.c b/lib/compress/zstd_fast.c index ee25bcbac8d..8130fc961ff 100644 --- a/lib/compress/zstd_fast.c +++ b/lib/compress/zstd_fast.c @@ -532,8 +532,8 @@ size_t ZSTD_compressBlock_fast_dictMatchState_generic( /* init */ DEBUGLOG(5, "ZSTD_compressBlock_fast_dictMatchState_generic"); ip0 += (dictAndPrefixLength == 0); - /* dictMatchState repCode checks don't currently handle repCode == 0 - * disabling. */ + /* Dictionary repcodes are validated when the CDict is attached. + * dictMatchState repCode checks don't handle repCode == 0 disabling. */ assert(offset_1 <= dictAndPrefixLength); assert(offset_2 <= dictAndPrefixLength); diff --git a/lib/compress/zstd_lazy.c b/lib/compress/zstd_lazy.c index 18b7b43948f..03c9992d0db 100644 --- a/lib/compress/zstd_lazy.c +++ b/lib/compress/zstd_lazy.c @@ -1601,8 +1601,8 @@ size_t ZSTD_compressBlock_lazy_generic( if (offset_1 > maxRep) offsetSaved1 = offset_1, offset_1 = 0; } if (isDxS) { - /* dictMatchState repCode checks don't currently handle repCode == 0 - * disabling. */ + /* Dictionary repcodes are validated when the CDict is attached. + * dictMatchState repCode checks don't handle repCode == 0 disabling. */ assert(offset_1 <= dictAndPrefixLength); assert(offset_2 <= dictAndPrefixLength); } diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 688eeb4f3e3..b992dbeeda4 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1200,6 +1200,284 @@ static unsigned test_get1BlockSummary(unsigned testNb) /* ============================================================= */ +typedef struct { + int fail; + size_t outstanding; +} FUZ_dictAllocState; + +static void* FUZ_dictMalloc(void* opaque, size_t size) +{ + FUZ_dictAllocState* const state = (FUZ_dictAllocState*)opaque; + void* allocation; + if (state->fail) return NULL; + allocation = malloc(size); + if (allocation != NULL) ++state->outstanding; + return allocation; +} + +static void FUZ_dictFree(void* opaque, void* allocation) +{ + FUZ_dictAllocState* const state = (FUZ_dictAllocState*)opaque; + if (allocation != NULL) { + CHECK(state->outstanding != 0); + --state->outstanding; + } + free(allocation); +} + +static void test_dictionaryLoadErrors(const void* dict, size_t dictSize) +{ + FUZ_dictAllocState state = { 0, 0 }; + ZSTD_customMem const customMem = { FUZ_dictMalloc, FUZ_dictFree, &state }; + ZSTD_CCtx* const cctx = ZSTD_createCCtx_advanced(customMem); + ZSTD_CCtx_params cdictParams; + BYTE const src[32] = { 0 }; + BYTE compressed[ZSTD_COMPRESSBOUND(sizeof(src))]; + size_t const baseline = state.outstanding; + size_t cSize; + + CHECK(cctx != NULL); + CHECK_Z(ZSTD_CCtxParams_init(&cdictParams, 1)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 1)); + CHECK_Z(ZSTD_CCtx_loadDictionary_byReference(cctx, dict, dictSize)); + + /* Actual allocation failures must keep their distinct error code. */ + state.fail = 1; + CHECK(ZSTD_createCDict_advanced2(dict, dictSize, ZSTD_dlm_byRef, + ZSTD_dct_fullDict, &cdictParams, customMem) == NULL); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_compress2(cctx, compressed, sizeof(compressed), + src, sizeof(src))), ZSTD_error_memory_allocation); + CHECK_EQ(state.outstanding, baseline); + state.fail = 0; + + /* A failed CDict initialization must free its partial allocation and + * leave the CCtx able to retry. The first eight bytes retain the magic + * number and dictionary ID, but omit the required entropy tables. */ + CHECK(dictSize > 8); + CHECK_Z(ZSTD_CCtx_loadDictionary_byReference(cctx, dict, 8)); + CHECK(ZSTD_createCDict_advanced2(dict, 8, ZSTD_dlm_byRef, + ZSTD_dct_fullDict, &cdictParams, customMem) == NULL); + CHECK_EQ(state.outstanding, baseline); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_compress2(cctx, compressed, sizeof(compressed), + src, sizeof(src))), ZSTD_error_dictionary_corrupted); + CHECK_EQ(state.outstanding, baseline); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_compress2(cctx, compressed, sizeof(compressed), + src, sizeof(src))), ZSTD_error_dictionary_corrupted); + CHECK_EQ(state.outstanding, baseline); + + CHECK_Z(ZSTD_CCtx_loadDictionary_byReference(cctx, dict, dictSize)); + CHECK_VAR(cSize, ZSTD_compress2(cctx, compressed, sizeof(compressed), src, sizeof(src))); + { ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + BYTE decoded[sizeof(src)]; + CHECK(dctx != NULL); + { CHECK_NEWV(dSize, ZSTD_decompress_usingDict(dctx, decoded, sizeof(decoded), + compressed, cSize, dict, dictSize)); + CHECK(dSize == sizeof(src) && memcmp(src, decoded, sizeof(src)) == 0); + } + ZSTD_freeDCtx(dctx); + } + ZSTD_freeCCtx(cctx); + CHECK_EQ(state.outstanding, 0); +} + +#if ZSTD_WINDOW_OVERFLOW_CORRECT_FREQUENTLY +/* dict has a valid full-dictionary header followed by at least 3 MB + 64 bytes of content. */ +static void test_dictionaryRepcodesAfterOverflow(BYTE* dict, size_t headerSize) +{ + /* Reaching the normal overflow threshold requires compressing a few GB. + * Only run this regression in frequent-correction mode, where a smaller + * dictionary and forceMaxWindow reproduce the same state cheaply. Loading + * the dictionary into the reused CCtx retains only 3 MB of its content. */ + size_t const contentSize = 3 MB + 64; + size_t const preloadSize = 256 KB - 64; + size_t const fullDictSize = headerSize + contentSize; + U32 const rep = (U32)contentSize - 16; + size_t const compressedCapacity = ZSTD_compressBound(128 KB); + void* const compressed = malloc(compressedCapacity); + ZSTD_CCtx* const fresh = ZSTD_createCCtx(); + ZSTD_CCtx* const reused = ZSTD_createCCtx(); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + ZSTD_parameters params = ZSTD_getParams(1, ZSTD_CONTENTSIZE_UNKNOWN, 0); + ZSTD_CCtx_params cctxParams; + BYTE src[4 KB]; + BYTE decoded[sizeof(src)]; + size_t remaining = preloadSize; + U32 u; + + CHECK(compressed != NULL && fresh != NULL && reused != NULL && dctx != NULL); + for (u = 0; u < ZSTD_REP_NUM; ++u) + MEM_writeLE32(dict + headerSize - 12 + 4 * u, repStartValue[u]); + MEM_writeLE32(dict + headerSize - 12, rep); + memcpy(src, dict + fullDictSize - rep, sizeof(src)); + + params.cParams.windowLog = 20; + params.cParams.hashLog = 18; + params.cParams.chainLog = 17; + CHECK_Z(ZSTD_CCtxParams_init_advanced(&cctxParams, params)); + CHECK_Z(ZSTD_CCtxParams_setParameter(&cctxParams, ZSTD_c_forceMaxWindow, 1)); + + /* First prove that exactly this dictionary and these parameters work. */ + CHECK_Z(ZSTD_compressBegin_advanced_internal(fresh, dict, fullDictSize, + ZSTD_dct_fullDict, ZSTD_dtlm_fast, NULL, &cctxParams, ZSTD_CONTENTSIZE_UNKNOWN)); + CHECK_EQ(fresh->blockState.matchState.window.nbOverflowCorrections, 0); + { CHECK_NEWV(cSize, ZSTD_compressEnd(fresh, compressed, compressedCapacity, src, sizeof(src))); + { CHECK_NEWV(dSize, ZSTD_decompress_usingDict(dctx, decoded, sizeof(decoded), + compressed, cSize, dict, fullDictSize)); + CHECK(dSize == sizeof(src) && memcmp(src, decoded, sizeof(src)) == 0); + } + } + + /* Advance the indices using actual compression, without replacing the + * workspace or manufacturing internal match-state indices. */ + CHECK_Z(ZSTD_compressBegin_advanced_internal(reused, NULL, 0, + ZSTD_dct_auto, ZSTD_dtlm_fast, NULL, &cctxParams, ZSTD_CONTENTSIZE_UNKNOWN)); + while (remaining != 0) { + size_t const chunkSize = MIN(remaining, 128 KB); + CHECK_Z(ZSTD_compressContinue(reused, compressed, compressedCapacity, + dict + headerSize, chunkSize)); + remaining -= chunkSize; + } + CHECK_Z(ZSTD_compressEnd(reused, compressed, compressedCapacity, NULL, 0)); + CHECK_EQ(reused->blockState.matchState.window.nbOverflowCorrections, 0); + CHECK_EQ((size_t)(reused->blockState.matchState.window.nextSrc + - reused->blockState.matchState.window.base), + preloadSize + ZSTD_WINDOW_START_INDEX); + + { size_t const result = ZSTD_compressBegin_advanced_internal(reused, dict, fullDictSize, + ZSTD_dct_fullDict, ZSTD_dtlm_fast, NULL, &cctxParams, ZSTD_CONTENTSIZE_UNKNOWN); + ZSTD_window_t const* const window = &reused->blockState.matchState.window; + U32 const loadedDictSize = (U32)(window->nextSrc - window->base) - window->lowLimit; + CHECK_EQ(window->nbOverflowCorrections, 1); + CHECK_EQ(loadedDictSize, 3 MB); + CHECK_LT(loadedDictSize, rep); + CHECK_Z(result); + } + { CHECK_NEWV(cSize, ZSTD_compressEnd(reused, compressed, compressedCapacity, src, sizeof(src))); + { CHECK_NEWV(dSize, ZSTD_decompress_usingDict(dctx, decoded, sizeof(decoded), + compressed, cSize, dict, fullDictSize)); + CHECK(dSize == sizeof(src) && memcmp(src, decoded, sizeof(src)) == 0); + } + } + + ZSTD_freeDCtx(dctx); + ZSTD_freeCCtx(reused); + ZSTD_freeCCtx(fresh); + free(compressed); +} +#endif + +static void test_largeDictionaryRepcodes(const void* dict, size_t dictSize) +{ + U32 const shortCacheMaxDictSize = (1U << (32 - ZSTD_SHORT_CACHE_TAG_BITS)) - ZSTD_WINDOW_START_INDEX; + struct DictCase { size_t contentSize; U32 rep; }; + struct DictCase const cases[] = { + { 20 MB, shortCacheMaxDictSize + 1 }, + { 20 MB, 20 MB - 16 }, + { 20 MB, shortCacheMaxDictSize }, + { 10 MB, 10 MB - 16 }, + }; + int const levels[] = { -5, 1, 2, 3, 4, 5 }; + size_t const headerSize = ZDICT_getDictHeaderSize(dict, dictSize); + BYTE* largeDict; + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + BYTE src[4 KB]; + BYTE compressed[8 KB]; + BYTE decoded[sizeof(src)]; + size_t ci, li; + U32 r; + + CHECK_Z(headerSize); + CHECK(headerSize >= 12); + largeDict = (BYTE*)malloc(headerSize + 20 MB); + CHECK(largeDict != NULL && cctx != NULL && dctx != NULL); + memcpy(largeDict, dict, headerSize); + RDG_genBuffer(largeDict + headerSize, 20 MB, 0.5, 0., 0); + +#if ZSTD_WINDOW_OVERFLOW_CORRECT_FREQUENTLY + test_dictionaryRepcodesAfterOverflow(largeDict, headerSize); +#endif + + for (ci = 0; ci < COUNTOF(cases); ++ci) { + size_t const largeDictSize = headerSize + cases[ci].contentSize; + /* Make the large repcode a possible match in the full dictionary. */ + memcpy(src, largeDict + largeDictSize - cases[ci].rep, sizeof(src)); + for (li = 0; li < COUNTOF(levels); ++li) { + int const level = levels[li]; + ZSTD_CCtx_params cdictParams; + ZSTD_compressionParameters cParams; + int reject; + CHECK_Z(ZSTD_CCtxParams_init(&cdictParams, level)); + cParams = ZSTD_getCParamsFromCCtxParams(&cdictParams, + ZSTD_CONTENTSIZE_UNKNOWN, largeDictSize, ZSTD_cpm_createCDict); + reject = (cParams.strategy == ZSTD_fast || cParams.strategy == ZSTD_dfast) + && cases[ci].rep > shortCacheMaxDictSize; + for (r = 0; r < ZSTD_REP_NUM; ++r) { + ZSTD_CDict* cdict; + U32 u; + size_t cSize; + ZSTD_inBuffer in = { src, sizeof(src), 0 }; + ZSTD_outBuffer out = { compressed, sizeof(compressed), 0 }; + for (u = 0; u < ZSTD_REP_NUM; ++u) + MEM_writeLE32(largeDict + headerSize - 12 + 4 * u, repStartValue[u]); + MEM_writeLE32(largeDict + headerSize - 12 + 4 * r, cases[ci].rep); + + cdict = ZSTD_createCDict_byReference(largeDict, largeDictSize, level); + if (reject) { + CHECK(cdict == NULL); + /* The copy constructor and advanced constructor share the check. */ + CHECK(ZSTD_createCDict(largeDict, largeDictSize, level) == NULL); + CHECK(ZSTD_createCDict_advanced(largeDict, largeDictSize, + ZSTD_dlm_byRef, ZSTD_dct_fullDict, + cParams, ZSTD_defaultCMem) == NULL); + + /* Local CDict creation is deferred until compression starts. */ + CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, level)); + CHECK_Z(ZSTD_CCtx_loadDictionary(cctx, largeDict, largeDictSize)); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_compress2(cctx, compressed, + sizeof(compressed), src, sizeof(src))), ZSTD_error_dictionary_corrupted); + CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); + CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, level)); + CHECK_Z(ZSTD_CCtx_loadDictionary_byReference(cctx, largeDict, largeDictSize)); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_compressStream2(cctx, &out, &in, + ZSTD_e_continue)), ZSTD_error_dictionary_corrupted); + + /* The dictionary is format-valid. The non-CDict path retains + * all of its content, so it must still round-trip. */ + CHECK_VAR(cSize, ZSTD_compress_usingDict(cctx, compressed, sizeof(compressed), + src, sizeof(src), largeDict, largeDictSize, level)); + } else { + CHECK(cdict != NULL); + CHECK_VAR(cSize, ZSTD_compress_usingCDict(cctx, compressed, sizeof(compressed), + src, sizeof(src), cdict)); + { CHECK_NEWV(dSize, ZSTD_decompress_usingDict(dctx, decoded, sizeof(decoded), + compressed, cSize, largeDict, largeDictSize)); + CHECK(dSize == sizeof(src) && memcmp(src, decoded, sizeof(src)) == 0); + } + + /* Start with unknown input size to exercise attached streaming. */ + CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); + CHECK_Z(ZSTD_CCtx_refCDict(cctx, cdict)); + CHECK_Z(ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue)); + CHECK(in.pos == in.size); + CHECK(ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_end) == 0); + cSize = out.pos; + ZSTD_freeCDict(cdict); + } + { CHECK_NEWV(dSize, ZSTD_decompress_usingDict(dctx, decoded, sizeof(decoded), + compressed, cSize, largeDict, largeDictSize)); + CHECK(dSize == sizeof(src) && memcmp(src, decoded, sizeof(src)) == 0); + } + } + } + } + + ZSTD_freeDCtx(dctx); + ZSTD_freeCCtx(cctx); + free(largeDict); +} + static int basicUnitTests(U32 const seed, double compressibility) { size_t const CNBuffSize = 5 MB; @@ -3442,6 +3720,14 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK : %u \n", (unsigned)dictHeaderSize); + DISPLAYLEVEL(3, "test%3i : Dictionary creation errors : ", testNb++); + test_dictionaryLoadErrors(dictBuffer, dictSize); + DISPLAYLEVEL(3, "OK \n"); + + DISPLAYLEVEL(3, "test%3i : Large dictionary repcodes : ", testNb++); + test_largeDictionaryRepcodes(dictBuffer, dictSize); + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : compress with dictionary : ", testNb++); cSize = ZSTD_compress_usingDict(cctx, compressedBuffer, compressedBufferSize, CNBuffer, CNBuffSize, From b09de2bfed18ffc44993e022c31127b3a347d945 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 16:20:14 -0800 Subject: [PATCH 37/94] linux: state the lit_capacity margin of zstd_compress_sequences_and_literals() Kernel callers read include/linux/zstd.h, which did not mention that lit_capacity must be at least 8 bytes larger than lit_size. This is now enforced, so a smaller capacity generates an error. --- contrib/linux-kernel/linux_zstd.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contrib/linux-kernel/linux_zstd.h b/contrib/linux-kernel/linux_zstd.h index 2f2a3c8b8a3..a3a1822a78f 100644 --- a/contrib/linux-kernel/linux_zstd.h +++ b/contrib/linux-kernel/linux_zstd.h @@ -677,7 +677,8 @@ typedef ZSTD_Sequence zstd_sequence; * @in_seqs_size: The number of sequences in in_seqs. * @literals: The literals associated to the sequences to be compressed. * @lit_size: The size of the literals in the literals buffer. - * @lit_capacity: The size of the literals buffer. + * @lit_capacity: The size of the literals buffer, which must be at least + * 8 bytes larger than lit_size. * @decompressed_size: The size of the input data * * Return: The compressed size or an error, which can be checked using From 038be8f273dce4facbce88737507240f9801f370 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 16:24:25 -0800 Subject: [PATCH 38/94] legacy: check the v0.5 litlength header error before its table log --- lib/legacy/zstd_v05.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/legacy/zstd_v05.c b/lib/legacy/zstd_v05.c index 264a54a7503..9389963e85f 100644 --- a/lib/legacy/zstd_v05.c +++ b/lib/legacy/zstd_v05.c @@ -3650,8 +3650,8 @@ static size_t ZSTDv05_loadEntropy(ZSTDv05_DCtx* dctx, const void* dict, size_t d dictSize -= matchlengthHeaderSize; litlengthHeaderSize = FSEv05_readNCount(litlengthNCount, &litlengthMaxValue, &litlengthLog, dict, dictSize); - if (litlengthLog > LLFSEv05Log) return ERROR(dictionary_corrupted); if (FSEv05_isError(litlengthHeaderSize)) return ERROR(dictionary_corrupted); + if (litlengthLog > LLFSEv05Log) return ERROR(dictionary_corrupted); errorCode = FSEv05_buildDTable(dctx->LLTable, litlengthNCount, litlengthMaxValue, litlengthLog); if (FSEv05_isError(errorCode)) return ERROR(dictionary_corrupted); From 18f8d17689dd1d92f8f9cdff6354f602a282a553 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 20:58:41 -0800 Subject: [PATCH 39/94] cmake: detect clang with the C compiler id in lib/CMakeLists.txt Without the tests, C++ is not enabled and CMAKE_CXX_COMPILER_ID is empty. A clang build for the MSVC ABI then named its static library zstd.lib, like the import library of the DLL (#4513), and a clang-cl build processed the resource file again, which 7e0324e12 had disabled. Add a Clang-CL CI job without the tests. --- .github/workflows/cmake-tests.yml | 6 ++++++ build/cmake/lib/CMakeLists.txt | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmake-tests.yml b/.github/workflows/cmake-tests.yml index 262c9ac15a1..d2fee2369b3 100644 --- a/.github/workflows/cmake-tests.yml +++ b/.github/workflows/cmake-tests.yml @@ -125,6 +125,12 @@ jobs: name: "Clang-CL" runner: "windows-2022" cmake_extra_flags: "-DCMAKE_COMPILE_WARNING_AS_ERROR=ON -DZSTD_BUILD_TESTS=ON" + - generator: "Visual Studio 17 2022" + flags: "-T ClangCL" + name: "Clang-CL (No ZSTD_BUILD_TESTS)" + runner: "windows-2022" + # Intentionally omit ZSTD_BUILD_TESTS: C++ is not enabled, so only the C compiler id is set + cmake_extra_flags: "-DCMAKE_COMPILE_WARNING_AS_ERROR=ON" - generator: "Visual Studio 17 2022" flags: "-T ClangCL -A x64 -DCMAKE_C_FLAGS=/arch:AVX2" name: "Clang-CL AVX2" diff --git a/build/cmake/lib/CMakeLists.txt b/build/cmake/lib/CMakeLists.txt index 82f4f50869c..df567e2fbaa 100644 --- a/build/cmake/lib/CMakeLists.txt +++ b/build/cmake/lib/CMakeLists.txt @@ -98,7 +98,7 @@ if (ZSTD_LEGACY_SUPPORT) ${LIBRARY_LEGACY_DIR}/zstd_v07.h) endif () -if (MSVC AND NOT (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")) +if (MSVC AND NOT (CMAKE_C_COMPILER_ID STREQUAL "Clang")) set(MSVC_RESOURCE_DIR ${ZSTD_SOURCE_DIR}/build/VS2010/libzstd-dll) set(PlatformDependResources ${MSVC_RESOURCE_DIR}/libzstd-dll.rc) else() @@ -189,7 +189,7 @@ if (MSVC) endif () # With MSVC static library needs to be renamed to avoid conflict with import library -if (MSVC OR (WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND NOT MINGW)) +if (MSVC OR (WIN32 AND CMAKE_C_COMPILER_ID STREQUAL "Clang" AND NOT MINGW)) set(STATIC_LIBRARY_BASE_NAME zstd_static) else () set(STATIC_LIBRARY_BASE_NAME zstd) From 6e51a2469e6900e41fb509f36f1bac575e657ef6 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 16:23:08 -0800 Subject: [PATCH 40/94] tests: cover truncated v0.5/v0.6 dictionary entropy tables --- tests/legacy.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/legacy.c b/tests/legacy.c index d2ae8ecd1f2..b6c372c8956 100644 --- a/tests/legacy.c +++ b/tests/legacy.c @@ -364,10 +364,14 @@ static int testLegacyDictionaries(void) } else { /* v0.5 and v0.6 have a four-byte magic, while v0.7 * also has a four-byte dictionary ID. Every truncated - * header must be treated as raw dictionary content. */ + * header must be treated as raw dictionary content. + * For v0.5 and v0.6, sizes 4 to 8 keep the magic but + * truncate the entropy tables, which must not be read + * past their end. v0.7 stops at 7: from 8 bytes, its + * magic is parsed as a zstd dictionary. */ if (frames[f].version < 5 || frames[f].version > 7) continue; dictContent[0] = (unsigned char)(0x30 + frames[f].version); - maxSize = frames[f].version == 7 ? 7 : 3; + maxSize = frames[f].version == 7 ? 7 : 8; } for (dictSize = 0; dictSize <= maxSize; ++dictSize) { void* const dict = malloc(dictSize == 0 ? 1 : dictSize); From c4cbcaac887a3b4e12b1a337c26ea71829a2ce28 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 21:51:24 -0800 Subject: [PATCH 41/94] ci: build cmake without the tests Without the tests, C++ is not enabled. Every CMake job on Linux and macOS enabled them, so the default build lost its warning flags and the x86-64 assembly without any CI failure (#4800). The new job sets CMAKE_CXX_COMPILER, as cross toolchain files do, and checks that the assembly is built. It fails before #4810, and when any of the three changes of #4810 to AddZstdCompilationFlags.cmake is reverted. --- .github/workflows/cmake-tests.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/cmake-tests.yml b/.github/workflows/cmake-tests.yml index 4c4172be82f..c8d4b5e5328 100644 --- a/.github/workflows/cmake-tests.yml +++ b/.github/workflows/cmake-tests.yml @@ -36,6 +36,21 @@ jobs: run: | cmake --build cmake-build --config Release + # Build without the tests, so C++ is not enabled, with CMAKE_CXX_COMPILER set as cross toolchain files do. + # The x86-64 assembly is only built when the compilation flags are applied. + cmake-c-only: + name: "CMake C only (No ZSTD_BUILD_TESTS)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 + - name: Configure and build + run: | + cmake -S build/cmake -B cmake-build -DCMAKE_BUILD_TYPE=Release -DCMAKE_COMPILE_WARNING_AS_ERROR=ON -DCMAKE_CXX_COMPILER=g++ + cmake --build cmake-build --parallel + - name: Check that the x86-64 assembly is built + run: | + nm cmake-build/lib/libzstd.a | grep -q HUF_decompress4X1_usingDTable_internal_fast_asm_loop + # Ubuntu-based cmake build using make wrapper # This test uses the make-driven cmake build to ensure compatibility # with the existing build system integration From 64c0474449b70f021ff1c1384fa9b60403c3afc2 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Wed, 11 Nov 2026 11:29:13 -0800 Subject: [PATCH 42/94] ci: run the cli-tests under ASan and UBSan --- .github/workflows/dev-long-tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index dcdb5674c00..46e17ed3898 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -127,6 +127,13 @@ jobs: - name: clang + ASan + UBSan + Test Zstd run: CC=clang make -j uasan-test-zstd Date: Tue, 10 Nov 2026 09:10:58 -0800 Subject: [PATCH 43/94] Reject overflowing CDict workspace sizes Share checked dictionary workspace sizing between size estimates, heap-backed CDicts, and static CDicts. Reject overflow in dictionary alignment, sanitizer padding, and the final workspace sum before allocating or copying. Add regressions for SIZE_MAX and nearby sizes, custom allocator rejection, static initialization, and valid small dictionaries. Document the estimator error return. --- lib/compress/zstd_compress.c | 59 +++++++++++------- lib/zstd.h | 2 + tests/invalidDictionaries.c | 112 +++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 22 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 9f2fe4e69d6..82d08208fa4 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -5591,6 +5591,29 @@ size_t ZSTD_compress(void* dst, size_t dstCapacity, /* ===== Dictionary API ===== */ +/* Returns the CDict workspace size, or an error if the dictionary copy would + * overflow while rounding, adding sanitizer redzones, or adding the workspace + * overhead. All by-copy CDict creation paths must use this calculation before + * reserving or copying the dictionary. */ +static size_t ZSTD_estimateCDictSize_internal(size_t dictSize, + size_t matchStateSize, + ZSTD_dictLoadMethod_e dictLoadMethod) +{ + size_t const baseSize = ZSTD_cwksp_alloc_size(sizeof(ZSTD_CDict)) + + ZSTD_cwksp_alloc_size(HUF_WORKSPACE_SIZE) + + matchStateSize; + if (dictLoadMethod == ZSTD_dlm_byRef) return baseSize; + { + size_t const alignedDictSize = ZSTD_cwksp_align(dictSize, sizeof(void*)); + size_t const dictAllocSize = ZSTD_cwksp_alloc_size(alignedDictSize); + RETURN_ERROR_IF(alignedDictSize < dictSize || dictAllocSize < alignedDictSize, + memory_allocation, "CDict size overflow"); + RETURN_ERROR_IF(dictAllocSize > (size_t)-1 - baseSize, + memory_allocation, "CDict workspace size overflow"); + return baseSize + dictAllocSize; + } +} + /*! ZSTD_estimateCDictSize_advanced() : * Estimate amount of memory that will be needed to create a dictionary with following arguments */ size_t ZSTD_estimateCDictSize_advanced( @@ -5598,14 +5621,12 @@ size_t ZSTD_estimateCDictSize_advanced( ZSTD_dictLoadMethod_e dictLoadMethod) { DEBUGLOG(5, "sizeof(ZSTD_CDict) : %u", (unsigned)sizeof(ZSTD_CDict)); - return ZSTD_cwksp_alloc_size(sizeof(ZSTD_CDict)) - + ZSTD_cwksp_alloc_size(HUF_WORKSPACE_SIZE) - /* enableDedicatedDictSearch == 1 ensures that CDict estimation will not be too small - * in case we are using DDS with row-hash. */ - + ZSTD_sizeof_matchState(&cParams, ZSTD_resolveRowMatchFinderMode(ZSTD_ps_auto, &cParams), - /* enableDedicatedDictSearch */ 1, /* forCCtx */ 0) - + (dictLoadMethod == ZSTD_dlm_byRef ? 0 - : ZSTD_cwksp_alloc_size(ZSTD_cwksp_align(dictSize, sizeof(void *)))); + /* enableDedicatedDictSearch == 1 ensures that CDict estimation will not be too small + * in case we are using DDS with row-hash. */ + return ZSTD_estimateCDictSize_internal(dictSize, + ZSTD_sizeof_matchState(&cParams, ZSTD_resolveRowMatchFinderMode(ZSTD_ps_auto, &cParams), + /* enableDedicatedDictSearch */ 1, /* forCCtx */ 0), + dictLoadMethod); } size_t ZSTD_estimateCDictSize(size_t dictSize, int compressionLevel) @@ -5687,16 +5708,15 @@ ZSTD_createCDict_advanced_internal(size_t dictSize, if ((!customMem.customAlloc) ^ (!customMem.customFree)) return NULL; DEBUGLOG(3, "ZSTD_createCDict_advanced_internal (dictSize=%u)", (unsigned)dictSize); - { size_t const workspaceSize = - ZSTD_cwksp_alloc_size(sizeof(ZSTD_CDict)) + - ZSTD_cwksp_alloc_size(HUF_WORKSPACE_SIZE) + - ZSTD_sizeof_matchState(&cParams, useRowMatchFinder, enableDedicatedDictSearch, /* forCCtx */ 0) + - (dictLoadMethod == ZSTD_dlm_byRef ? 0 - : ZSTD_cwksp_alloc_size(ZSTD_cwksp_align(dictSize, sizeof(void*)))); - void* const workspace = ZSTD_customMalloc(workspaceSize, customMem); + { size_t const workspaceSize = ZSTD_estimateCDictSize_internal(dictSize, + ZSTD_sizeof_matchState(&cParams, useRowMatchFinder, enableDedicatedDictSearch, /* forCCtx */ 0), + dictLoadMethod); + void* workspace; ZSTD_cwksp ws; ZSTD_CDict* cdict; + if (ZSTD_isError(workspaceSize)) return NULL; + workspace = ZSTD_customMalloc(workspaceSize, customMem); if (!workspace) { ZSTD_customFree(workspace, customMem); return NULL; @@ -5864,16 +5884,13 @@ const ZSTD_CDict* ZSTD_initStaticCDict( ZSTD_ParamSwitch_e const useRowMatchFinder = ZSTD_resolveRowMatchFinderMode(ZSTD_ps_auto, &cParams); /* enableDedicatedDictSearch == 1 ensures matchstate is not too small in case this CDict will be used for DDS + row hash */ size_t const matchStateSize = ZSTD_sizeof_matchState(&cParams, useRowMatchFinder, /* enableDedicatedDictSearch */ 1, /* forCCtx */ 0); - size_t const neededSize = ZSTD_cwksp_alloc_size(sizeof(ZSTD_CDict)) - + (dictLoadMethod == ZSTD_dlm_byRef ? 0 - : ZSTD_cwksp_alloc_size(ZSTD_cwksp_align(dictSize, sizeof(void*)))) - + ZSTD_cwksp_alloc_size(HUF_WORKSPACE_SIZE) - + matchStateSize; + size_t const neededSize = ZSTD_estimateCDictSize_internal(dictSize, matchStateSize, dictLoadMethod); ZSTD_CDict* cdict; ZSTD_CCtx_params params; DEBUGLOG(4, "ZSTD_initStaticCDict (dictSize==%u)", (unsigned)dictSize); if ((size_t)workspace & 7) return NULL; /* 8-aligned */ + if (ZSTD_isError(neededSize) || workspaceSize < neededSize) return NULL; { ZSTD_cwksp ws; @@ -5883,8 +5900,6 @@ const ZSTD_CDict* ZSTD_initStaticCDict( ZSTD_cwksp_move(&cdict->workspace, &ws); } - if (workspaceSize < neededSize) return NULL; - ZSTD_CCtxParams_init(¶ms, 0); params.cParams = cParams; params.useRowMatchFinder = useRowMatchFinder; diff --git a/lib/zstd.h b/lib/zstd.h index ef5160084aa..15fd643870c 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1817,6 +1817,8 @@ ZSTDLIB_STATIC_API size_t ZSTD_estimateDStreamSize_fromFrame(const void* src, si /*! ZSTD_estimate?DictSize() : * ZSTD_estimateCDictSize() will bet that src size is relatively "small", and content is copied, like ZSTD_createCDict(). * ZSTD_estimateCDictSize_advanced() makes it possible to control compression parameters precisely, like ZSTD_createCDict_advanced(). + * The CDict estimates return an error code if dictSize is too large. + * Check the result with ZSTD_isError(). * Note : dictionaries created by reference (`ZSTD_dlm_byRef`) are logically smaller. */ ZSTDLIB_STATIC_API size_t ZSTD_estimateCDictSize(size_t dictSize, int compressionLevel); diff --git a/tests/invalidDictionaries.c b/tests/invalidDictionaries.c index 66caa9e99cb..992036012ee 100644 --- a/tests/invalidDictionaries.c +++ b/tests/invalidDictionaries.c @@ -9,6 +9,9 @@ */ #include +#include +#include +#define ZSTD_STATIC_LINKING_ONLY #include "zstd.h" static const char invalidRepCode[] = { @@ -38,8 +41,117 @@ static const dictionary dictionaries[] = { {NULL, 0}, }; +static void* countAllocations(void* opaque, size_t size) { + size_t* const count = (size_t*)opaque; + ++*count; + (void)size; + return NULL; +} + +static void ignoreFree(void* opaque, void* address) { + (void)opaque; + (void)address; +} + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "CDict size test failed at line %d: %s\n", \ + __LINE__, #condition); \ + goto cleanup; \ + } \ + } while (0) + +static int testCDictSizeOverflow(void) { + static const char dict[] = "a small raw content dictionary"; + ZSTD_compressionParameters cParams = ZSTD_getCParams(1, 0, 0); + ZSTD_CCtx_params* const params = ZSTD_createCCtxParams(); + ZSTD_CDict* cdict = NULL; + void* workspace = NULL; + size_t allocCount = 0; + ZSTD_customMem const customMem = {countAllocations, ignoreFree, &allocCount}; + size_t baseSize; + size_t workspaceSize; + int result = 1; + + CHECK(params != NULL); + CHECK(!ZSTD_isError(ZSTD_CCtxParams_init(params, 1))); + cParams.windowLog = ZSTD_WINDOWLOG_MIN; + cParams.hashLog = ZSTD_HASHLOG_MIN; + cParams.chainLog = ZSTD_CHAINLOG_MIN; + baseSize = ZSTD_estimateCDictSize_advanced(0, cParams, ZSTD_dlm_byCopy); + CHECK(!ZSTD_isError(baseSize)); + workspaceSize = ZSTD_estimateCDictSize_advanced(sizeof(dict), cParams, ZSTD_dlm_byCopy); + CHECK(!ZSTD_isError(workspaceSize)); + workspace = malloc(workspaceSize); + CHECK(workspace != NULL); + + { + size_t const maxSize = (size_t)-1; + size_t const badSizes[] = { + maxSize, + maxSize - 1, + maxSize - (sizeof(void*) - 1), + /* The estimator includes a conservative match-state allowance, so stay + * well inside the overflow range for both static and heap workspaces. */ + maxSize - baseSize / 2, + }; + size_t i; + for (i = 0; i < sizeof(badSizes) / sizeof(badSizes[0]); ++i) { + size_t const dictSize = badSizes[i]; + /* Exercise the public reproducer as well as each workspace-sizing path. + * The dictionary is deliberately much smaller than the claimed size: + * overflow must be rejected before the input is read or copied. */ + cdict = ZSTD_createCDict(dict, dictSize, 1); + CHECK(cdict == NULL); + CHECK(ZSTD_isError(ZSTD_estimateCDictSize(dictSize, 1))); + CHECK(ZSTD_isError(ZSTD_estimateCDictSize_advanced(dictSize, cParams, ZSTD_dlm_byCopy))); + cdict = ZSTD_createCDict_advanced(dict, dictSize, ZSTD_dlm_byCopy, + ZSTD_dct_auto, cParams, customMem); + CHECK(cdict == NULL); + cdict = ZSTD_createCDict_advanced2(dict, dictSize, ZSTD_dlm_byCopy, + ZSTD_dct_auto, params, customMem); + CHECK(cdict == NULL); + CHECK(allocCount == 0); + CHECK(ZSTD_initStaticCDict(workspace, workspaceSize, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto, cParams) == NULL); + /* By-reference estimates don't include a dictionary allocation. */ + CHECK(ZSTD_estimateCDictSize_advanced(dictSize, cParams, ZSTD_dlm_byRef) == baseSize); + } + } + + { + size_t const goodSizes[] = {0, 1, sizeof(void*) - 1, sizeof(void*), sizeof(dict)}; + size_t i; + for (i = 0; i < sizeof(goodSizes) / sizeof(goodSizes[0]); ++i) { + size_t const dictSize = goodSizes[i]; + size_t const neededSize = ZSTD_estimateCDictSize_advanced(dictSize, cParams, ZSTD_dlm_byCopy); + CHECK(!ZSTD_isError(neededSize)); + CHECK(neededSize <= workspaceSize); + cdict = ZSTD_createCDict(dict, dictSize, 1); + CHECK(cdict != NULL); + ZSTD_freeCDict(cdict); + cdict = NULL; + CHECK(ZSTD_initStaticCDict(workspace, neededSize, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto, cParams) != NULL); + CHECK(ZSTD_initStaticCDict(workspace, baseSize, dict, dictSize, + ZSTD_dlm_byRef, ZSTD_dct_auto, cParams) != NULL); + } + } + result = 0; + +cleanup: + ZSTD_freeCDict(cdict); + ZSTD_freeCCtxParams(params); + free(workspace); + return result; +} + +#undef CHECK + int main(int argc, const char** argv) { const dictionary *dict; + if (testCDictSizeOverflow()) return 3; for (dict = dictionaries; dict->data != NULL; ++dict) { ZSTD_CDict *cdict; ZSTD_DDict *ddict; From 7d4671262184372367dd80163cd522ca8fa30a06 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 08:21:11 -0800 Subject: [PATCH 44/94] Reject overflowing DDict workspace sizes Check the dictionary-size addition in ZSTD_estimateDDictSize() and use the checked estimate in ZSTD_initStaticDDict() before copying dictionary content. Add regressions for SIZE_MAX and the first overflowing size, unchanged by-reference and large representable estimates, and exact-size static workspaces. Document the DDict estimator error return. --- lib/decompress/zstd_ddict.c | 10 +++-- lib/zstd.h | 2 +- tests/invalidDictionaries.c | 74 ++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/lib/decompress/zstd_ddict.c b/lib/decompress/zstd_ddict.c index 309ec0d0364..45b2ced9511 100644 --- a/lib/decompress/zstd_ddict.c +++ b/lib/decompress/zstd_ddict.c @@ -190,13 +190,12 @@ const ZSTD_DDict* ZSTD_initStaticDDict( ZSTD_dictLoadMethod_e dictLoadMethod, ZSTD_dictContentType_e dictContentType) { - size_t const neededSpace = sizeof(ZSTD_DDict) - + (dictLoadMethod == ZSTD_dlm_byRef ? 0 : dictSize); + size_t const neededSpace = ZSTD_estimateDDictSize(dictSize, dictLoadMethod); ZSTD_DDict* const ddict = (ZSTD_DDict*)sBuffer; assert(sBuffer != NULL); assert(dict != NULL); if ((size_t)sBuffer & 7) return NULL; /* 8-aligned */ - if (sBufferSize < neededSpace) return NULL; + if (ZSTD_isError(neededSpace) || sBufferSize < neededSpace) return NULL; if (dictLoadMethod == ZSTD_dlm_byCopy) { ZSTD_memcpy(ddict+1, dict, dictSize); /* local copy */ dict = ddict+1; @@ -224,7 +223,10 @@ size_t ZSTD_freeDDict(ZSTD_DDict* ddict) * Note : dictionary created by reference using ZSTD_dlm_byRef are smaller */ size_t ZSTD_estimateDDictSize(size_t dictSize, ZSTD_dictLoadMethod_e dictLoadMethod) { - return sizeof(ZSTD_DDict) + (dictLoadMethod == ZSTD_dlm_byRef ? 0 : dictSize); + if (dictLoadMethod == ZSTD_dlm_byRef) return sizeof(ZSTD_DDict); + RETURN_ERROR_IF(dictSize > (size_t)-1 - sizeof(ZSTD_DDict), + memory_allocation, "DDict size overflow"); + return sizeof(ZSTD_DDict) + dictSize; } size_t ZSTD_sizeof_DDict(const ZSTD_DDict* ddict) diff --git a/lib/zstd.h b/lib/zstd.h index 15fd643870c..b9e61217b95 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1817,7 +1817,7 @@ ZSTDLIB_STATIC_API size_t ZSTD_estimateDStreamSize_fromFrame(const void* src, si /*! ZSTD_estimate?DictSize() : * ZSTD_estimateCDictSize() will bet that src size is relatively "small", and content is copied, like ZSTD_createCDict(). * ZSTD_estimateCDictSize_advanced() makes it possible to control compression parameters precisely, like ZSTD_createCDict_advanced(). - * The CDict estimates return an error code if dictSize is too large. + * These estimates return an error code if dictSize is too large. * Check the result with ZSTD_isError(). * Note : dictionaries created by reference (`ZSTD_dlm_byRef`) are logically smaller. */ diff --git a/tests/invalidDictionaries.c b/tests/invalidDictionaries.c index 992036012ee..70e42a0808a 100644 --- a/tests/invalidDictionaries.c +++ b/tests/invalidDictionaries.c @@ -56,7 +56,7 @@ static void ignoreFree(void* opaque, void* address) { #define CHECK(condition) \ do { \ if (!(condition)) { \ - fprintf(stderr, "CDict size test failed at line %d: %s\n", \ + fprintf(stderr, "Dictionary size test failed at line %d: %s\n", \ __LINE__, #condition); \ goto cleanup; \ } \ @@ -147,11 +147,83 @@ static int testCDictSizeOverflow(void) { return result; } +static int testDDictSizeOverflow(void) { + static const char dict[] = "a small raw content dictionary"; + size_t const maxSize = (size_t)-1; + size_t const baseSize = ZSTD_estimateDDictSize(0, ZSTD_dlm_byCopy); + size_t const workspaceSize = ZSTD_estimateDDictSize(sizeof(dict), ZSTD_dlm_byCopy); + ZSTD_DDict* ddict = NULL; + void* workspace = NULL; + int result = 1; + + CHECK(!ZSTD_isError(baseSize)); + CHECK(!ZSTD_isError(workspaceSize)); + CHECK(workspaceSize == baseSize + sizeof(dict)); + workspace = malloc(workspaceSize); + CHECK(workspace != NULL); + + { + size_t const badSizes[] = { + maxSize, + maxSize - 1, + /* The smallest dictSize that overflows the sum. */ + maxSize - baseSize + 1, + }; + size_t i; + for (i = 0; i < sizeof(badSizes) / sizeof(badSizes[0]); ++i) { + size_t const dictSize = badSizes[i]; + /* Reject overflow before accessing the deliberately short dictionary. + * An error code must not be mistaken for a valid workspace size. */ + CHECK(ZSTD_initStaticDDict(workspace, workspaceSize, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto) == NULL); + CHECK(ZSTD_initStaticDDict(workspace, maxSize, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto) == NULL); + CHECK(ZSTD_isError(ZSTD_estimateDDictSize(dictSize, ZSTD_dlm_byCopy))); + CHECK(ZSTD_estimateDDictSize(dictSize, ZSTD_dlm_byRef) == baseSize); + } + /* Large representable estimates are unchanged. Stay below the values + * reserved for error codes without allocating the advertised size. */ + CHECK(ZSTD_estimateDDictSize(maxSize - 2 * baseSize, ZSTD_dlm_byCopy) + == maxSize - baseSize); + } + + { + size_t const goodSizes[] = {0, 1, sizeof(void*) - 1, sizeof(void*), sizeof(dict)}; + size_t i; + for (i = 0; i < sizeof(goodSizes) / sizeof(goodSizes[0]); ++i) { + size_t const dictSize = goodSizes[i]; + size_t const neededSize = ZSTD_estimateDDictSize(dictSize, ZSTD_dlm_byCopy); + CHECK(neededSize == baseSize + dictSize); + CHECK(ZSTD_estimateDDictSize(dictSize, ZSTD_dlm_byRef) == baseSize); + CHECK(ZSTD_initStaticDDict(workspace, neededSize - 1, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto) == NULL); + CHECK(ZSTD_initStaticDDict(workspace, neededSize, dict, dictSize, + ZSTD_dlm_byCopy, ZSTD_dct_auto) != NULL); + CHECK(ZSTD_initStaticDDict(workspace, baseSize - 1, dict, dictSize, + ZSTD_dlm_byRef, ZSTD_dct_auto) == NULL); + CHECK(ZSTD_initStaticDDict(workspace, baseSize, dict, dictSize, + ZSTD_dlm_byRef, ZSTD_dct_auto) != NULL); + ddict = ZSTD_createDDict(dict, dictSize); + CHECK(ddict != NULL); + CHECK(ZSTD_sizeof_DDict(ddict) == neededSize); + ZSTD_freeDDict(ddict); + ddict = NULL; + } + } + result = 0; + +cleanup: + ZSTD_freeDDict(ddict); + free(workspace); + return result; +} + #undef CHECK int main(int argc, const char** argv) { const dictionary *dict; if (testCDictSizeOverflow()) return 3; + if (testDDictSizeOverflow()) return 4; for (dict = dictionaries; dict->data != NULL; ++dict) { ZSTD_CDict *cdict; ZSTD_DDict *ddict; From eb1c858841e9e9846abee359e8130d46ed0243ab Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Tue, 10 Nov 2026 09:56:11 -0800 Subject: [PATCH 45/94] Fix static DCtx allocator initialization and dictionary loading Before this fix, the new regression fails on its first zeroed-workspace check: ZSTD_DCtx_loadDictionary() succeeds instead of returning ZSTD_error_memory_allocation. It allocates an internal DDict despite the static-context no-allocation contract. With a dirty or reused workspace, customMem is uninitialized, so the same path can call stale allocator and free function pointers. Pass the allocator into the shared DCtx initializer so heap contexts retain their configured allocator and static and stack contexts initialize customMem to ZSTD_defaultCMem. Reject internal DDict creation for nonempty dictionaries on static contexts with ZSTD_error_memory_allocation. This also covers by-reference dictionary loading, prefix references, and ZSTD_initDStream_usingDict(). Empty dictionary loads and references to caller-owned DDicts remain supported. Document the restriction and the static-DDict alternative. The regression exercises zeroed and dirty workspaces with both static constructors, checks dictionary and prefix rejection and allocator initialization, and verifies successful one-shot and streaming decompression using a caller-owned static DDict. --- CHANGELOG | 1 + lib/decompress/zstd_decompress.c | 12 ++--- lib/zstd.h | 5 ++- tests/fuzzer.c | 77 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index eb46d711872..17e09aae2f0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ v1.6.0 (Dec 2025) +fix: on a static DCtx, `ZSTD_DCtx_loadDictionary()` and `ZSTD_DCtx_refPrefix()` now return `ZSTD_error_memory_allocation` for nonempty dictionaries and prefixes instead of allocating and leaking an internal DDict api: legacy format support is now disabled by default build: `ZSTD_LEGACY_SUPPORT` defaults to `0` in Makefile and CMake diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index e27a9680971..44b8597aa81 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -248,8 +248,9 @@ static void ZSTD_DCtx_resetParameters(ZSTD_DCtx* dctx) dctx->maxBlockSizeParam = 0; } -static void ZSTD_initDCtx_internal(ZSTD_DCtx* dctx) +static void ZSTD_initDCtx_internal(ZSTD_DCtx* dctx, ZSTD_customMem customMem) { + dctx->customMem = customMem; dctx->staticSize = 0; dctx->ddict = NULL; dctx->ddictLocal = NULL; @@ -284,7 +285,7 @@ ZSTD_DCtx* ZSTD_initStaticDCtx(void *workspace, size_t workspaceSize) if ((size_t)workspace & 7) return NULL; /* 8-aligned */ if (workspaceSize < sizeof(ZSTD_DCtx)) return NULL; /* minimum size */ - ZSTD_initDCtx_internal(dctx); + ZSTD_initDCtx_internal(dctx, ZSTD_defaultCMem); dctx->staticSize = workspaceSize; dctx->inBuff = (char*)(dctx+1); return dctx; @@ -295,8 +296,7 @@ static ZSTD_DCtx* ZSTD_createDCtx_internal(ZSTD_customMem customMem) { { ZSTD_DCtx* const dctx = (ZSTD_DCtx*)ZSTD_customMalloc(sizeof(*dctx), customMem); if (!dctx) return NULL; - dctx->customMem = customMem; - ZSTD_initDCtx_internal(dctx); + ZSTD_initDCtx_internal(dctx, customMem); return dctx; } } @@ -1211,7 +1211,7 @@ size_t ZSTD_decompress(void* dst, size_t dstCapacity, const void* src, size_t sr return regenSize; #else /* stack mode */ ZSTD_DCtx dctx; - ZSTD_initDCtx_internal(&dctx); + ZSTD_initDCtx_internal(&dctx, ZSTD_defaultCMem); return ZSTD_decompressDCtx(&dctx, dst, dstCapacity, src, srcSize); #endif } @@ -1704,6 +1704,8 @@ size_t ZSTD_DCtx_loadDictionary_advanced(ZSTD_DCtx* dctx, RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); if (dict && dictSize != 0) { + RETURN_ERROR_IF(dctx->staticSize, memory_allocation, + "static DCtx can't allocate an internal DDict"); dctx->ddictLocal = ZSTD_createDDict_advanced(dict, dictSize, dictLoadMethod, dictContentType, dctx->customMem); RETURN_ERROR_IF(dctx->ddictLocal == NULL, memory_allocation, "NULL pointer!"); dctx->ddict = dctx->ddictLocal; diff --git a/lib/zstd.h b/lib/zstd.h index b9e61217b95..31e24771aec 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1842,7 +1842,10 @@ ZSTDLIB_STATIC_API size_t ZSTD_estimateDDictSize(size_t dictSize, ZSTD_dictLoadM * Note 3 : cParams : use ZSTD_getCParams() to convert a compression level * into its associated cParams. * Limitation 1 : currently not compatible with internal dictionary creation, triggered by - * ZSTD_CCtx_loadDictionary(), ZSTD_initCStream_usingDict() or ZSTD_initDStream_usingDict(). + * ZSTD_CCtx_loadDictionary(), ZSTD_DCtx_loadDictionary(), ZSTD_DCtx_refPrefix(), + * ZSTD_initCStream_usingDict() or ZSTD_initDStream_usingDict(). + * For one-shot decompression, ZSTD_decompress_usingDict() needs no allocation. + * Otherwise, use ZSTD_initStaticDDict() + ZSTD_DCtx_refDDict(). * Limitation 2 : static cctx currently not compatible with multi-threading. * Limitation 3 : static dctx is incompatible with legacy support. */ diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 9f9e8a1c191..0735e9ddbde 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -47,6 +47,7 @@ #include "pool.h" /* POOL_sizeof */ #include "compress/hist.h" /* HIST_count_wksp */ #include "compress/zstd_compress_internal.h" /* ZSTD_get1BlockSummary */ +#include "decompress/zstd_decompress_internal.h" /* ZSTD_DCtx */ /*-************************************ @@ -2856,6 +2857,82 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : static DCtx dictionary allocation : ", testNb++); + { + static const char dict[] = "static DCtx dictionary regression test"; + size_t const dictSize = sizeof(dict); + size_t const dctxSize = ZSTD_estimateDCtxSize(); + size_t const dstreamSize = ZSTD_estimateDStreamSize(1 KB); + size_t const ddictSize = ZSTD_estimateDDictSize(dictSize, ZSTD_dlm_byRef); + void* const workspace = malloc(dstreamSize); + void* const ddictWorkspace = malloc(ddictSize); + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + const ZSTD_DDict* ddict; + char compressed[ZSTD_COMPRESSBOUND(sizeof(dict))]; + char decoded[sizeof(dict)]; + size_t compressedSize; + int fill; + int stream; + CHECK(workspace != NULL && ddictWorkspace != NULL && cctx != NULL); + ddict = ZSTD_initStaticDDict(ddictWorkspace, ddictSize, dict, dictSize, + ZSTD_dlm_byRef, ZSTD_dct_rawContent); + CHECK(ddict != NULL); + CHECK_VAR(compressedSize, ZSTD_compress_usingDict(cctx, compressed, sizeof(compressed), + dict, dictSize, dict, dictSize, 1)); + CHECK_Z(ZSTD_freeCCtx(cctx)); + + /* Both zeroed and reused workspaces must reject internal DDict + * creation without invoking an allocator. */ + for (fill = 0; fill < 2; ++fill) { + for (stream = 0; stream < 2; ++stream) { + ZSTD_DCtx* dctx; + memset(workspace, fill ? 0xA5 : 0, dstreamSize); + dctx = stream ? ZSTD_initStaticDStream(workspace, dstreamSize) + : ZSTD_initStaticDCtx(workspace, dctxSize); + CHECK(dctx != NULL); + CHECK_EQ(ZSTD_DCtx_loadDictionary(dctx, dict, dictSize), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_DCtx_loadDictionary_byReference(dctx, dict, dictSize), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_DCtx_loadDictionary_advanced(dctx, dict, dictSize, ZSTD_dlm_byCopy, ZSTD_dct_rawContent), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_DCtx_loadDictionary_advanced(dctx, dict, dictSize, ZSTD_dlm_byRef, ZSTD_dct_rawContent), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_DCtx_refPrefix(dctx, dict, dictSize), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_DCtx_refPrefix_advanced(dctx, dict, dictSize, ZSTD_dct_auto), ERROR(memory_allocation)); + CHECK_EQ(ZSTD_initDStream_usingDict(dctx, dict, dictSize), ERROR(memory_allocation)); + CHECK_EQ(dctx->customMem.customAlloc, NULL); + CHECK_EQ(dctx->customMem.customFree, NULL); + CHECK_EQ(dctx->customMem.opaque, NULL); + + /* Clearing a dictionary does not allocate. */ + CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, NULL, dictSize)); + CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, dict, 0)); + CHECK_Z(ZSTD_DCtx_loadDictionary_byReference(dctx, NULL, dictSize)); + CHECK_Z(ZSTD_DCtx_loadDictionary_byReference(dctx, dict, 0)); + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, NULL, dictSize)); + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, dict, 0)); + CHECK_Z(ZSTD_initDStream_usingDict(dctx, NULL, dictSize)); + CHECK_Z(ZSTD_initDStream_usingDict(dctx, dict, 0)); + + /* A caller-owned DDict remains usable without allocation. */ + CHECK_Z(ZSTD_DCtx_refDDict(dctx, ddict)); + CHECK_EQ(ZSTD_decompressDCtx(dctx, decoded, sizeof(decoded), compressed, compressedSize), dictSize); + CHECK_EQ(memcmp(decoded, dict, dictSize), 0); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters)); + if (stream) { + ZSTD_inBuffer input = { compressed, compressedSize, 0 }; + ZSTD_outBuffer output = { decoded, sizeof(decoded), 0 }; + CHECK_Z(ZSTD_initDStream_usingDDict(dctx, ddict)); + CHECK_EQ(ZSTD_decompressStream(dctx, &output, &input), 0); + CHECK_EQ(input.pos, compressedSize); + CHECK_EQ(output.pos, dictSize); + CHECK_EQ(memcmp(decoded, dict, dictSize), 0); + } + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_and_parameters)); + } + } + free(ddictWorkspace); + free(workspace); + } + DISPLAYLEVEL(3, "OK \n"); + /* Static CCtx tests */ #define STATIC_CCTX_LEVEL 4 DISPLAYLEVEL(3, "test%3i : create static CCtx for level %u : ", testNb++, STATIC_CCTX_LEVEL); From 7c2b359739ec200864858a147e23f4d97e756010 Mon Sep 17 00:00:00 2001 From: Chieler Date: Thu, 15 Oct 2026 13:54:35 -0500 Subject: [PATCH 46/94] Drop single-use prefix in ZSTD_DCtx_reset if frame started When decompressing a malformed frame header using a single-use prefix (ZSTD_DCtx_refPrefix), decompression aborts before ZSTD_getDDict() can consume the dictionary. A subsequent ZSTD_DCtx_reset(session_only) reset streamStage back to zdss_init without clearing dictUses == ZSTD_use_once, allowing the discarded prefix buffer to leak into subsequent frames and causing heap use-after-free if the caller freed the prefix buffer. Clear the prefix during session reset if a frame decompression was already initiated (dctx->streamStage != zdss_init). Add regression tests in zstreamtest to verify that the prefix is dropped after a bad header and preserved if reset occurs prior to ingesting input. Fixes #4843 --- lib/decompress/zstd_decompress.c | 4 ++++ tests/zstreamtest.c | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 44b8597aa81..311393736b7 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -1953,6 +1953,10 @@ size_t ZSTD_DCtx_reset(ZSTD_DCtx* dctx, ZSTD_ResetDirective reset) { if ( (reset == ZSTD_reset_session_only) || (reset == ZSTD_reset_session_and_parameters) ) { + /* A prefix is only used once : if a frame was started, it is abandoned now, + * so the prefix must not leak into the next frame */ + if (dctx->streamStage != zdss_init && dctx->dictUses == ZSTD_use_once) + ZSTD_clearDict(dctx); dctx->streamStage = zdss_init; dctx->noForwardProgress = 0; dctx->isFrameDecompression = 1; diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index f671dea4451..b303499504c 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -1406,6 +1406,39 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) DISPLAYLEVEL(3, "OK (%s)\n", ZSTD_getErrorName(r)); } + DISPLAYLEVEL(3, "test%3i : prefix is dropped by session reset after a rejected frame header : ", testNb++); + { BYTE const badHeader[6] = { 0x28, 0xB5, 0x2F, 0xFD, 0x08, 0x00 }; /* reserved bit set */ + CHECK_Z( ZSTD_DCtx_refPrefix(zd, dictionary.start, dictionary.filled) ); + outBuff.dst = decodedBuffer; + outBuff.size = CNBufferSize; + outBuff.pos = 0; + inBuff.src = badHeader; + inBuff.size = sizeof(badHeader); + inBuff.pos = 0; + if (!ZSTD_isError(ZSTD_decompressStream(zd, &outBuff, &inBuff))) goto _output_error; + CHECK_Z( ZSTD_DCtx_reset(zd, ZSTD_reset_session_only) ); + outBuff.pos = 0; + inBuff.src = compressedBuffer; + inBuff.size = cSize; + inBuff.pos = 0; + if (!ZSTD_isError(ZSTD_decompressStream(zd, &outBuff, &inBuff))) goto _output_error; /* prefix must be gone */ + CHECK_Z( ZSTD_DCtx_reset(zd, ZSTD_reset_session_only) ); + } + DISPLAYLEVEL(3, "OK \n"); + + DISPLAYLEVEL(3, "test%3i : prefix survives session reset before any input : ", testNb++); + CHECK_Z( ZSTD_DCtx_refPrefix(zd, dictionary.start, dictionary.filled) ); + CHECK_Z( ZSTD_DCtx_reset(zd, ZSTD_reset_session_only) ); + outBuff.dst = decodedBuffer; + outBuff.size = CNBufferSize; + outBuff.pos = 0; + inBuff.src = compressedBuffer; + inBuff.size = cSize; + inBuff.pos = 0; + CHECK_Z( ZSTD_decompressStream(zd, &outBuff, &inBuff) ); + if (outBuff.pos != CNBufferSize) goto _output_error; /* must regenerate whole input */ + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : compress again with ZSTD_compressStream2 : ", testNb++); outBuff.dst = compressedBuffer; outBuff.size = compressedBufferSize; From c46fdaac9018f4ce76d0d86af98c0aedee6e1495 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Wed, 11 Nov 2026 10:56:42 -0800 Subject: [PATCH 47/94] ci: add an all-green job to each pull request workflow A ruleset can only require status checks by name, and the 104 jobs of these workflows are often added or renamed. Each workflow now ends with an all-green job that needs every other job, and fails if any of them failed or was cancelled, so that a ruleset on dev can require only these 5 checks. A new job must be added to the needs of the all-green job of its workflow. --- .github/workflows/android-ndk-build.yml | 13 +++++++ .github/workflows/cmake-tests.yml | 18 +++++++++ .github/workflows/commit.yml | 15 ++++++++ .github/workflows/dev-long-tests.yml | 40 ++++++++++++++++++++ .github/workflows/dev-short-tests.yml | 50 +++++++++++++++++++++++++ 5 files changed, 136 insertions(+) diff --git a/.github/workflows/android-ndk-build.yml b/.github/workflows/android-ndk-build.yml index cf2e69cbbb8..6634d135f94 100644 --- a/.github/workflows/android-ndk-build.yml +++ b/.github/workflows/android-ndk-build.yml @@ -48,3 +48,16 @@ jobs: -DANDROID_PLATFORM=android-21 \ -DCMAKE_BUILD_TYPE=Release cmake --build . --parallel + + # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. + # always(): if a job fails, a skipped all-green would count as passed. + all-green: + name: "android-ndk-build all green" + if: always() + needs: + - build + runs-on: ubuntu-latest + steps: + - name: Check that no job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/cmake-tests.yml b/.github/workflows/cmake-tests.yml index c8d4b5e5328..9906a92a264 100644 --- a/.github/workflows/cmake-tests.yml +++ b/.github/workflows/cmake-tests.yml @@ -187,3 +187,21 @@ jobs: cmake -DCMAKE_BUILD_TYPE=Release ${{ env.COMMON_CMAKE_FLAGS }} -DZSTD_ZSTREAM_FLAGS=-T${{ env.QUICK_TEST_TIME }} -DZSTD_FUZZER_FLAGS=-T${{ env.QUICK_TEST_TIME }} -DZSTD_FULLBENCH_FLAGS=-i1 .. make -j$(sysctl -n hw.ncpu) ctest -V + + # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. + # always(): if a job fails, a skipped all-green would count as passed. + all-green: + name: "cmake-tests all green" + if: always() + needs: + - cmake-root-basic + - cmake-c-only + - cmake-ubuntu-basic + - cmake-cross-platform-spaces + - cmake-windows-comprehensive + - cmake-macos-arm64 + runs-on: ubuntu-latest + steps: + - name: Check that no job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 9825a16b7b9..ac1991c23c4 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -102,3 +102,18 @@ jobs: - uses: actions/upload-artifact@v7.0.1 with: path: "/tmp/circleci-artifacts" + + # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. + # always(): if a job fails, a skipped all-green would count as passed. + all-green: + name: "commit all green" + if: always() + needs: + - short-tests-0 + - short-tests-1 + - regression-test + runs-on: ubuntu-latest + steps: + - name: Check that no job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index dcdb5674c00..19a010454ba 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -336,3 +336,43 @@ jobs: with: name: ${{ matrix.sanitizer }}-artifacts path: ./out/artifacts + + # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. + # always(): if a job fails, a skipped all-green would count as passed. + all-green: + name: "dev-long-tests all green" + if: always() + needs: + - make-all + - make-test + - make-test-macos + - make-test-32bit + - test-largeDictionary + - no-intrinsics-fuzztest + - tsan-zstreamtest + - uasan-zstreamtest + - tsan-fuzztest + - big-tests-zstreamtest32 + - gcc-8-asan-ubsan-testzstd + - clang-asan-ubsan-testzstd + - gcc-asan-ubsan-testzstd-32bit + - gcc-8-asan-ubsan-fuzz + - clang-asan-ubsan-fuzz + - clang-asan-ubsan-legacy + - gcc-asan-ubsan-fuzz32 + - clang-asan-fuzz32 + - asan-ubsan-regression + - clang-asan-ubsan-regression + - msan-regression + - clang-msan-fuzz-unoptimized + - clang-msan-fuzz + - clang-msan-testzstd + - armfuzz + - valgrind-fuzz-test + - mingw-long-test + - oss-fuzz + runs-on: ubuntu-latest + steps: + - name: Check that no job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index c4db0a4963b..7d1b682f87e 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -697,3 +697,53 @@ jobs: source /opt/intel/oneapi/setvars.sh make CC=icx check make CC=icx -C tests test-cli-tests + + # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. + # always(): if a job fails, a skipped all-green would count as passed. + all-green: + name: "dev-short-tests all green" + if: always() + needs: + - linux-kernel + - benchmarking + - check-32bit + - build-c89 + - build-zstd-dll + - gcc-7-libzstd + - cpp-gnu90-c99-compatibility + - mingw-cross-compilation + - armbuild + - bourne-shell + - zlib-wrapper + - lz4-threadpool-libs + - gcc-make-all-avx2 + - gcc-make-all-32bit + - gcc-make-all-32bit-avx2 + - gcc-8-make + - make-external-compressors + - implicit-fall-through + - meson-linux + - meson-mingw-cross-compilation + - meson-windows + - msbuild-visual-studio + - libzstd-size + - minimal-decompressor-macros + - dynamic-bmi2 + - test-variants + - qemu-consistency + - mingw-short-test + - visual-runtime-tests + - cygwin-tests + - pkg-config + - versions-compatibility + - clangbuild + - gcc-pgo + - clang-pgo + - musl-build + - intel-cet-compatibility + - icx + runs-on: ubuntu-latest + steps: + - name: Check that no job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 From f404793344449dee47d8cbb337a4a067c5b04482 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Tue, 10 Nov 2026 17:13:35 -0800 Subject: [PATCH 48/94] lib: report corrupted dictionaries in ZSTD_DCtx_loadDictionary() --- lib/decompress/zstd_ddict.c | 44 ++++++++++++++++++++++---------- lib/decompress/zstd_ddict.h | 8 ++++++ lib/decompress/zstd_decompress.c | 4 +-- tests/fuzzer.c | 13 ++++++++++ 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/lib/decompress/zstd_ddict.c b/lib/decompress/zstd_ddict.c index 45b2ced9511..0e550ce03ea 100644 --- a/lib/decompress/zstd_ddict.c +++ b/lib/decompress/zstd_ddict.c @@ -142,25 +142,41 @@ static size_t ZSTD_initDDict_internal(ZSTD_DDict* ddict, return 0; } +size_t ZSTD_createDDict_advanced_internal(ZSTD_DDict** ddictPtr, + const void* dict, size_t dictSize, + ZSTD_dictLoadMethod_e dictLoadMethod, + ZSTD_dictContentType_e dictContentType, + ZSTD_customMem customMem) +{ + ZSTD_DDict* ddict; + *ddictPtr = NULL; + RETURN_ERROR_IF((!customMem.customAlloc) ^ (!customMem.customFree), + parameter_combination_unsupported, "Invalid custom allocator"); + + ddict = (ZSTD_DDict*) ZSTD_customMalloc(sizeof(ZSTD_DDict), customMem); + RETURN_ERROR_IF(ddict == NULL, memory_allocation, ""); + ddict->cMem = customMem; + { size_t const initResult = ZSTD_initDDict_internal(ddict, + dict, dictSize, + dictLoadMethod, dictContentType); + if (ZSTD_isError(initResult)) { + ZSTD_freeDDict(ddict); + return initResult; + } } + *ddictPtr = ddict; + return 0; +} + ZSTD_DDict* ZSTD_createDDict_advanced(const void* dict, size_t dictSize, ZSTD_dictLoadMethod_e dictLoadMethod, ZSTD_dictContentType_e dictContentType, ZSTD_customMem customMem) { - if ((!customMem.customAlloc) ^ (!customMem.customFree)) return NULL; - - { ZSTD_DDict* const ddict = (ZSTD_DDict*) ZSTD_customMalloc(sizeof(ZSTD_DDict), customMem); - if (ddict == NULL) return NULL; - ddict->cMem = customMem; - { size_t const initResult = ZSTD_initDDict_internal(ddict, - dict, dictSize, - dictLoadMethod, dictContentType); - if (ZSTD_isError(initResult)) { - ZSTD_freeDDict(ddict); - return NULL; - } } - return ddict; - } + ZSTD_DDict* ddict; + size_t const err = ZSTD_createDDict_advanced_internal(&ddict, + dict, dictSize, dictLoadMethod, dictContentType, customMem); + if (ZSTD_isError(err)) return NULL; + return ddict; } /*! ZSTD_createDDict() : diff --git a/lib/decompress/zstd_ddict.h b/lib/decompress/zstd_ddict.h index c4ca8877a07..c9295ec1051 100644 --- a/lib/decompress/zstd_ddict.h +++ b/lib/decompress/zstd_ddict.h @@ -39,6 +39,14 @@ size_t ZSTD_DDict_dictSize(const ZSTD_DDict* ddict); void ZSTD_copyDDictParameters(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict); +/* Same as ZSTD_createDDict_advanced(), but returns the error code on failure, + * in which case *ddictPtr is set to NULL. */ +size_t ZSTD_createDDict_advanced_internal(ZSTD_DDict** ddictPtr, + const void* dict, size_t dictSize, + ZSTD_dictLoadMethod_e dictLoadMethod, + ZSTD_dictContentType_e dictContentType, + ZSTD_customMem customMem); + #endif /* ZSTD_DDICT_H */ diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 311393736b7..6553a5f0852 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -1706,8 +1706,8 @@ size_t ZSTD_DCtx_loadDictionary_advanced(ZSTD_DCtx* dctx, if (dict && dictSize != 0) { RETURN_ERROR_IF(dctx->staticSize, memory_allocation, "static DCtx can't allocate an internal DDict"); - dctx->ddictLocal = ZSTD_createDDict_advanced(dict, dictSize, dictLoadMethod, dictContentType, dctx->customMem); - RETURN_ERROR_IF(dctx->ddictLocal == NULL, memory_allocation, "NULL pointer!"); + FORWARD_IF_ERROR(ZSTD_createDDict_advanced_internal(&dctx->ddictLocal, + dict, dictSize, dictLoadMethod, dictContentType, dctx->customMem), ""); dctx->ddict = dctx->ddictLocal; dctx->dictUses = ZSTD_use_indefinitely; } diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 0735e9ddbde..1f0615deb0a 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -3967,6 +3967,19 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : loading a dictionary containing only header should return dictionary_corrupted : ", testNb++); + { ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + const char* const headerOnly = "\x37\xa4\x30\xec\x11\x22\x33\x44"; + assert(dctx != NULL); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_DCtx_loadDictionary(dctx, headerOnly, 8)), ZSTD_error_dictionary_corrupted); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_DCtx_loadDictionary_byReference(dctx, headerOnly, 8)), ZSTD_error_dictionary_corrupted); + /* the context remains usable */ + CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, dictBuffer, dictSize)); + CHECK_EQ(ZSTD_decompressDCtx(dctx, decodedBuffer, CNBuffSize, compressedBuffer, cSize), CNBuffSize); + ZSTD_freeDCtx(dctx); + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3d : bufferless api with cdict : ", testNb++); { ZSTD_CDict* const cdict = ZSTD_createCDict(dictBuffer, dictSize, 1); ZSTD_DCtx* const dctx = ZSTD_createDCtx(); From 52dac33baa6ecc1804ea94b71e50568b7ce73179 Mon Sep 17 00:00:00 2001 From: kevinjzhang <47681004+kevinjzhang@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:21:38 -0800 Subject: [PATCH 49/94] Fix out-of-bounds read in COVER_selectDict shrink loop (#4922) COVER_selectDict() reused dictContentSize for both the raw content size and the finalized dictionary size (header + content). The shrink loop was bounded by the finalized size while taking candidates from customDictContentEnd - candidateSize, so a candidate between the content size and the finalized size read before the start of the content buffer. Use separate variables for the content size, the finalized sizes and the candidate content size, and bound the loop by the content size. As a result, candidates now double the content size (256, 512, ...) rather than the previous candidate's finalized size. The shrink path is not reachable through the public API today: ZDICT_optimizeTrainFromBuffer_cover() and _fastCover() force shrinkDict = 0. Add a fuzzer unit test that calls COVER_selectDict() with content smaller than ZDICT_DICTSIZE_MIN. On the old code ASan reports a heap-buffer-overflow, and plain builds fail the result check. Fixes #4750. Co-authored-by: Kevin Zhang --- lib/dictBuilder/cover.c | 35 +++++++++++++++---------------- tests/fuzzer.c | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/lib/dictBuilder/cover.c b/lib/dictBuilder/cover.c index 83debc0da9b..921052657ee 100644 --- a/lib/dictBuilder/cover.c +++ b/lib/dictBuilder/cover.c @@ -1067,6 +1067,8 @@ COVER_dictSelection_t COVER_selectDict(BYTE* customDictContent, size_t dictBuffe size_t largestDict = 0; size_t largestCompressed = 0; + size_t candidateContentSize = 0; + size_t candidateDict = 0; BYTE* customDictContentEnd = customDictContent + dictContentSize; BYTE* largestDictbuffer = (BYTE*)malloc(dictBufferCapacity); @@ -1081,20 +1083,20 @@ COVER_dictSelection_t COVER_selectDict(BYTE* customDictContent, size_t dictBuffe /* Initial dictionary size and compressed size */ memcpy(largestDictbuffer, customDictContent, dictContentSize); - dictContentSize = ZDICT_finalizeDictionary( + largestDict = ZDICT_finalizeDictionary( largestDictbuffer, dictBufferCapacity, customDictContent, dictContentSize, samplesBuffer, samplesSizes, nbFinalizeSamples, params.zParams); - if (ZDICT_isError(dictContentSize)) { + if (ZDICT_isError(largestDict)) { free(largestDictbuffer); free(candidateDictBuffer); - return COVER_dictSelectionError(dictContentSize); + return COVER_dictSelectionError(largestDict); } totalCompressedSize = COVER_checkTotalCompressedSize(params, samplesSizes, samplesBuffer, offsets, nbCheckSamples, nbSamples, - largestDictbuffer, dictContentSize); + largestDictbuffer, largestDict); if (ZSTD_isError(totalCompressedSize)) { free(largestDictbuffer); @@ -1104,31 +1106,29 @@ COVER_dictSelection_t COVER_selectDict(BYTE* customDictContent, size_t dictBuffe if (params.shrinkDict == 0) { free(candidateDictBuffer); - return setDictSelection(largestDictbuffer, dictContentSize, totalCompressedSize); + return setDictSelection(largestDictbuffer, largestDict, totalCompressedSize); } - largestDict = dictContentSize; largestCompressed = totalCompressedSize; - dictContentSize = ZDICT_DICTSIZE_MIN; + candidateContentSize = ZDICT_DICTSIZE_MIN; - /* Largest dict is initially at least ZDICT_DICTSIZE_MIN */ - while (dictContentSize < largestDict) { + while (candidateContentSize < dictContentSize) { memcpy(candidateDictBuffer, largestDictbuffer, largestDict); - dictContentSize = ZDICT_finalizeDictionary( - candidateDictBuffer, dictBufferCapacity, customDictContentEnd - dictContentSize, dictContentSize, + candidateDict = ZDICT_finalizeDictionary( + candidateDictBuffer, dictBufferCapacity, customDictContentEnd - candidateContentSize, candidateContentSize, samplesBuffer, samplesSizes, nbFinalizeSamples, params.zParams); - if (ZDICT_isError(dictContentSize)) { + if (ZDICT_isError(candidateDict)) { free(largestDictbuffer); free(candidateDictBuffer); - return COVER_dictSelectionError(dictContentSize); + return COVER_dictSelectionError(candidateDict); } totalCompressedSize = COVER_checkTotalCompressedSize(params, samplesSizes, samplesBuffer, offsets, nbCheckSamples, nbSamples, - candidateDictBuffer, dictContentSize); + candidateDictBuffer, candidateDict); if (ZSTD_isError(totalCompressedSize)) { free(largestDictbuffer); @@ -1138,14 +1138,13 @@ COVER_dictSelection_t COVER_selectDict(BYTE* customDictContent, size_t dictBuffe if ((double)totalCompressedSize <= (double)largestCompressed * regressionTolerance) { free(largestDictbuffer); - return setDictSelection( candidateDictBuffer, dictContentSize, totalCompressedSize ); + return setDictSelection( candidateDictBuffer, candidateDict, totalCompressedSize ); } - dictContentSize *= 2; + candidateContentSize *= 2; } - dictContentSize = largestDict; totalCompressedSize = largestCompressed; free(candidateDictBuffer); - return setDictSelection( largestDictbuffer, dictContentSize, totalCompressedSize ); + return setDictSelection( largestDictbuffer, largestDict, totalCompressedSize ); } /** diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 0735e9ddbde..e7f34e18add 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -48,6 +48,7 @@ #include "compress/hist.h" /* HIST_count_wksp */ #include "compress/zstd_compress_internal.h" /* ZSTD_get1BlockSummary */ #include "decompress/zstd_decompress_internal.h" /* ZSTD_DCtx */ +#include "dictBuilder/cover.h" /* COVER_selectDict */ /*-************************************ @@ -3781,6 +3782,51 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : COVER_selectDict shrinkDict with content < ZDICT_DICTSIZE_MIN : ", testNb++); + /* The shrink loop tries suffixes of the content, starting at ZDICT_DICTSIZE_MIN. + * With smaller content no suffix fits, so the full dictionary must be returned. + * The content is allocated at its exact size, so under ASan any read before it + * is reported as a heap-buffer-overflow. */ + { size_t offsets[8]; + unsigned const nbSelectSamples = (unsigned)(sizeof(offsets) / sizeof(offsets[0])); + size_t const contentSize = ZDICT_DICTSIZE_MIN - 16; + size_t const selectCapacity = 4 KB; + BYTE* const content = (BYTE*)malloc(contentSize); + BYTE* const expected = (BYTE*)malloc(selectCapacity); + ZDICT_cover_params_t coverParams; + COVER_dictSelection_t selection; + size_t expectedSize; + unsigned u; + if (content == NULL || expected == NULL) { + free(content); + free(expected); + goto _output_error; + } + memcpy(content, CNBuffer, contentSize); + for (u = 0; u < nbSelectSamples; u++) offsets[u] = u * sampleUnitSize; + memset(&coverParams, 0, sizeof(coverParams)); + coverParams.splitPoint = 1.0; + coverParams.shrinkDict = 1; + coverParams.shrinkDictMaxRegression = 100000; /* accept any candidate */ + expectedSize = ZDICT_finalizeDictionary(expected, selectCapacity, + content, contentSize, + CNBuffer, samplesSizes, nbSelectSamples, + coverParams.zParams); + selection = COVER_selectDict(content, selectCapacity, contentSize, + (const BYTE*)CNBuffer, samplesSizes, nbSelectSamples, + nbSelectSamples, nbSelectSamples, coverParams, offsets, 0); + { int const ok = !ZDICT_isError(expectedSize) + && !COVER_dictSelectionIsError(selection) + && selection.dictSize == expectedSize + && !memcmp(selection.dictContent, expected, expectedSize); + COVER_dictSelectionFree(selection); + free(content); + free(expected); + if (!ok) goto _output_error; + } + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : check dictID : ", testNb++); dictID = ZDICT_getDictID(dictBuffer, dictSize); if (dictID==0) goto _output_error; From 02343bace65afacb04c84f6596f84b90c3d72525 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Wed, 11 Nov 2026 16:26:26 -0800 Subject: [PATCH 50/94] Revert "ci: add an all-green job to each pull request workflow" This reverts commit c46fdaac9018f4ce76d0d86af98c0aedee6e1495. Each all-green job needed every job of its workflow listed by hand. --- .github/workflows/android-ndk-build.yml | 13 ------- .github/workflows/cmake-tests.yml | 18 --------- .github/workflows/commit.yml | 15 -------- .github/workflows/dev-long-tests.yml | 40 -------------------- .github/workflows/dev-short-tests.yml | 50 ------------------------- 5 files changed, 136 deletions(-) diff --git a/.github/workflows/android-ndk-build.yml b/.github/workflows/android-ndk-build.yml index 6634d135f94..cf2e69cbbb8 100644 --- a/.github/workflows/android-ndk-build.yml +++ b/.github/workflows/android-ndk-build.yml @@ -48,16 +48,3 @@ jobs: -DANDROID_PLATFORM=android-21 \ -DCMAKE_BUILD_TYPE=Release cmake --build . --parallel - - # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. - # always(): if a job fails, a skipped all-green would count as passed. - all-green: - name: "android-ndk-build all green" - if: always() - needs: - - build - runs-on: ubuntu-latest - steps: - - name: Check that no job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 diff --git a/.github/workflows/cmake-tests.yml b/.github/workflows/cmake-tests.yml index 9906a92a264..c8d4b5e5328 100644 --- a/.github/workflows/cmake-tests.yml +++ b/.github/workflows/cmake-tests.yml @@ -187,21 +187,3 @@ jobs: cmake -DCMAKE_BUILD_TYPE=Release ${{ env.COMMON_CMAKE_FLAGS }} -DZSTD_ZSTREAM_FLAGS=-T${{ env.QUICK_TEST_TIME }} -DZSTD_FUZZER_FLAGS=-T${{ env.QUICK_TEST_TIME }} -DZSTD_FULLBENCH_FLAGS=-i1 .. make -j$(sysctl -n hw.ncpu) ctest -V - - # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. - # always(): if a job fails, a skipped all-green would count as passed. - all-green: - name: "cmake-tests all green" - if: always() - needs: - - cmake-root-basic - - cmake-c-only - - cmake-ubuntu-basic - - cmake-cross-platform-spaces - - cmake-windows-comprehensive - - cmake-macos-arm64 - runs-on: ubuntu-latest - steps: - - name: Check that no job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index ac1991c23c4..9825a16b7b9 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -102,18 +102,3 @@ jobs: - uses: actions/upload-artifact@v7.0.1 with: path: "/tmp/circleci-artifacts" - - # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. - # always(): if a job fails, a skipped all-green would count as passed. - all-green: - name: "commit all green" - if: always() - needs: - - short-tests-0 - - short-tests-1 - - regression-test - runs-on: ubuntu-latest - steps: - - name: Check that no job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index 19a010454ba..dcdb5674c00 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -336,43 +336,3 @@ jobs: with: name: ${{ matrix.sanitizer }}-artifacts path: ./out/artifacts - - # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. - # always(): if a job fails, a skipped all-green would count as passed. - all-green: - name: "dev-long-tests all green" - if: always() - needs: - - make-all - - make-test - - make-test-macos - - make-test-32bit - - test-largeDictionary - - no-intrinsics-fuzztest - - tsan-zstreamtest - - uasan-zstreamtest - - tsan-fuzztest - - big-tests-zstreamtest32 - - gcc-8-asan-ubsan-testzstd - - clang-asan-ubsan-testzstd - - gcc-asan-ubsan-testzstd-32bit - - gcc-8-asan-ubsan-fuzz - - clang-asan-ubsan-fuzz - - clang-asan-ubsan-legacy - - gcc-asan-ubsan-fuzz32 - - clang-asan-fuzz32 - - asan-ubsan-regression - - clang-asan-ubsan-regression - - msan-regression - - clang-msan-fuzz-unoptimized - - clang-msan-fuzz - - clang-msan-testzstd - - armfuzz - - valgrind-fuzz-test - - mingw-long-test - - oss-fuzz - runs-on: ubuntu-latest - steps: - - name: Check that no job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index 7d1b682f87e..c4db0a4963b 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -697,53 +697,3 @@ jobs: source /opt/intel/oneapi/setvars.sh make CC=icx check make CC=icx -C tests test-cli-tests - - # Required by the ruleset on dev, instead of the jobs above: list all of them in needs. - # always(): if a job fails, a skipped all-green would count as passed. - all-green: - name: "dev-short-tests all green" - if: always() - needs: - - linux-kernel - - benchmarking - - check-32bit - - build-c89 - - build-zstd-dll - - gcc-7-libzstd - - cpp-gnu90-c99-compatibility - - mingw-cross-compilation - - armbuild - - bourne-shell - - zlib-wrapper - - lz4-threadpool-libs - - gcc-make-all-avx2 - - gcc-make-all-32bit - - gcc-make-all-32bit-avx2 - - gcc-8-make - - make-external-compressors - - implicit-fall-through - - meson-linux - - meson-mingw-cross-compilation - - meson-windows - - msbuild-visual-studio - - libzstd-size - - minimal-decompressor-macros - - dynamic-bmi2 - - test-variants - - qemu-consistency - - mingw-short-test - - visual-runtime-tests - - cygwin-tests - - pkg-config - - versions-compatibility - - clangbuild - - gcc-pgo - - clang-pgo - - musl-build - - intel-cet-compatibility - - icx - runs-on: ubuntu-latest - steps: - - name: Check that no job failed or was cancelled - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 From 8532f54b20f02f6791ad9909460cf25309914a55 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Wed, 11 Nov 2026 16:28:25 -0800 Subject: [PATCH 51/94] ci: add a job that waits for all the other workflows On pull requests into dev, the all-workflows job waits until every other workflow run on the head commit is completed, and fails if any of them did not succeed. It finds the workflows through the API, so there is no list to maintain. A workflow that does not run at all is not seen. It is not required for merging: its behavior is observed first. --- .github/workflows/all-workflows.yml | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/all-workflows.yml diff --git a/.github/workflows/all-workflows.yml b/.github/workflows/all-workflows.yml new file mode 100644 index 00000000000..e708c05566d --- /dev/null +++ b/.github/workflows/all-workflows.yml @@ -0,0 +1,48 @@ +name: all-workflows +# Waits for the other workflows run on the head commit of the pull request, +# and fails if any of them did not succeed. It finds them by itself: there is +# no list to maintain. It cannot see a workflow that does not run at all. + +concurrency: + group: all-workflows-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: [ dev ] + +permissions: + actions: read + +jobs: + check: + name: "all workflows passed" + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - name: Wait for the other workflows + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Latest run of each workflow on the head commit, except this workflow + query="[.workflow_runs[] | select(.name != \"$GITHUB_WORKFLOW\")] + | group_by(.workflow_id) | map(max_by(.run_number))[] + | [.name, .status, .conclusion // \"-\"] | @tsv" + # Stop when they are all completed, and unchanged since the previous poll. + # A failed API call is retried at the next poll. + prev="" + while true; do + sleep 30 + runs=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$HEAD_SHA&per_page=100" --jq "$query") || continue + if [ -n "$runs" ] && [ "$runs" = "$prev" ] && printf '%s\n' "$runs" | awk -F'\t' '$2 != "completed" {n++} END {exit n > 0}'; then + break + fi + prev=$runs + done + printf '%s\n' "$runs" + failed=$(printf '%s\n' "$runs" | awk -F'\t' '$3 != "success" && $3 != "skipped" && $3 != "neutral"') + if [ -n "$failed" ]; then + printf 'Did not succeed:\n%s\n' "$failed" + exit 1 + fi From 4b80d7d275d60718c565912e3393f8a9fae98b8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:39:56 +0000 Subject: [PATCH 52/94] build(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v7.0.1...v7.0.2) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/commit.yml | 2 +- .github/workflows/dev-long-tests.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/windows-artifacts.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 9825a16b7b9..4d2a8bfa22b 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -99,6 +99,6 @@ jobs: echo " are still good, copy it into the repo and commit it." echo "> diff tests/regression/results.csv $CIRCLE_ARTIFACTS/results.csv" diff tests/regression/results.csv $CIRCLE_ARTIFACTS/results.csv - - uses: actions/upload-artifact@v7.0.1 + - uses: actions/upload-artifact@v7.0.2 with: path: "/tmp/circleci-artifacts" diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index dcdb5674c00..9b5ab884fc7 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -331,7 +331,7 @@ jobs: dry-run: false sanitizer: ${{ matrix.sanitizer }} - name: Upload Crash - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # tag=v7.0.2 if: failure() && steps.build.outcome == 'success' with: name: ${{ matrix.sanitizer }}-artifacts diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 5f99c754c22..afc40d0625f 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -51,7 +51,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # tag=v7.0.2 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/windows-artifacts.yml b/.github/workflows/windows-artifacts.yml index 1b035966968..72b8a0797c6 100644 --- a/.github/workflows/windows-artifacts.yml +++ b/.github/workflows/windows-artifacts.yml @@ -93,7 +93,7 @@ jobs: mv bin/ zstd-${{ github.ref_name }}-${{matrix.ziparch}}/ - name: Publish zstd-$VERSION-${{matrix.ziparch}}.zip for manual inspection - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # tag=v7.0.2 with: compression-level: 9 # maximum compression if-no-files-found: error # defaults to `warn` From 13192827d379d47d398403c5a3eed8dbddab2901 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Wed, 11 Nov 2026 17:59:51 -0800 Subject: [PATCH 53/94] Reject ZSTD_decompressContinue() without ZSTD_decompressBegin() After a finished frame, ZSTD_decompressContinue() accepted srcSize == 0 and read 4 bytes from src, crashing when src is NULL. A new DCtx left the decoder state uninitialized. Both cases now return stage_wrong. --- lib/decompress/zstd_decompress.c | 5 +++++ tests/fuzzer.c | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 6553a5f0852..185ba0285cb 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -261,6 +261,8 @@ static void ZSTD_initDCtx_internal(ZSTD_DCtx* dctx, ZSTD_customMem customMem) dctx->inBuffSize = 0; dctx->outBuffSize = 0; dctx->streamStage = zdss_init; + dctx->stage = ZSTDds_getFrameHeaderSize; + dctx->expected = 0; /* ZSTD_decompressContinue() requires ZSTD_decompressBegin() first */ #if defined(ZSTD_LEGACY_SUPPORT) && (ZSTD_LEGACY_SUPPORT>=1) dctx->legacyContext = NULL; dctx->previousLegacyVersion = 0; @@ -1275,6 +1277,9 @@ static int ZSTD_isSkipFrame(ZSTD_DCtx* dctx) { return dctx->stage == ZSTDds_skip size_t ZSTD_decompressContinue(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize) { DEBUGLOG(5, "ZSTD_decompressContinue (srcSize:%u)", (unsigned)srcSize); + /* The previous frame is finished, or the context was never initialized */ + RETURN_ERROR_IF(dctx->stage == ZSTDds_getFrameHeaderSize && dctx->expected == 0, + stage_wrong, "missing init (ZSTD_decompressBegin)"); /* Sanity check */ RETURN_ERROR_IF(srcSize != ZSTD_nextSrcSizeToDecompressWithInputSize(dctx, srcSize), srcSize_wrong, "not allowed"); ZSTD_checkContinuity(dctx, dst, dstCapacity); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 5f0c82a3578..b3e0d6a6473 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -723,6 +723,39 @@ static void test_decompressSplitLitBuffer(unsigned tnb) free(src); } +static void test_decompressContinueWithoutBegin(unsigned tnb) +{ + char src[1000]; + char compressed[ZSTD_COMPRESSBOUND(sizeof(src))]; + char decoded[sizeof(src)]; + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + size_t cSize, cPos = 0, dPos = 0; + CHECK(dctx != NULL); + + DISPLAYLEVEL(3, "test%3u : ZSTD_decompressContinue() without ZSTD_decompressBegin() : ", tnb); + memset(src, 'a', sizeof(src)); + CHECK_VAR(cSize, ZSTD_compress(compressed, sizeof(compressed), src, sizeof(src), 1)); + + /* new context */ + CHECK_EQ(ZSTD_nextSrcSizeToDecompress(dctx), 0); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_decompressContinue(dctx, decoded, sizeof(decoded), NULL, 0)), ZSTD_error_stage_wrong); + + /* finished frame */ + CHECK_Z(ZSTD_decompressBegin(dctx)); + while (ZSTD_nextSrcSizeToDecompress(dctx) != 0) { + size_t const inSize = ZSTD_nextSrcSizeToDecompress(dctx); + CHECK_NEWV(outSize, ZSTD_decompressContinue(dctx, decoded + dPos, sizeof(decoded) - dPos, compressed + cPos, inSize)); + cPos += inSize; + dPos += outSize; + } + CHECK_EQ(cPos, cSize); + CHECK_EQ(dPos, sizeof(src)); + CHECK_EQ(ZSTD_getErrorCode(ZSTD_decompressContinue(dctx, decoded, sizeof(decoded), NULL, 0)), ZSTD_error_stage_wrong); + DISPLAYLEVEL(3, "OK \n"); + + ZSTD_freeDCtx(dctx); +} + static unsigned test_histCountWksp(unsigned seed, unsigned testNb) { static const unsigned symLowLimits[] = { 0, 27, 0, 0, 27, 42, 0, 0, 27, 42, 27, 42 }; @@ -1532,6 +1565,8 @@ static int basicUnitTests(U32 const seed, double compressibility) test_decompressSplitLitBuffer(testNb++); + test_decompressContinueWithoutBegin(testNb++); + test_setCParams(testNb++); DISPLAYLEVEL(3, "test%3u : ZSTD_adjustCParams : ", testNb++); From be8784e8f0ec9428c557b74efe2362cda27e3a31 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 15:25:04 -0800 Subject: [PATCH 54/94] Clarify bufferless decompression initialization requirements Require ZSTD_decompressBegin() or a dictionary-taking variant before the first ZSTD_decompressContinue() call for every frame, including after ZSTD_DCtx_reset(). Explain that an interrupted frame must be restarted from its beginning after a session reset. --- lib/decompress/zstd_decompress.c | 2 ++ lib/zstd.h | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 6553a5f0852..c0a32c13c9e 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -1269,6 +1269,8 @@ ZSTD_nextInputType_e ZSTD_nextInputType(ZSTD_DCtx* dctx) { static int ZSTD_isSkipFrame(ZSTD_DCtx* dctx) { return dctx->stage == ZSTDds_skipFrame; } /** ZSTD_decompressContinue() : + * The frame must first be initialized with ZSTD_decompressBegin() or one of its dictionary-taking variants. + * ZSTD_DCtx_reset() does not replace this initialization. * srcSize : must be the exact nb of bytes expected (see ZSTD_nextSrcSizeToDecompress()) * @return : nb of bytes generated into `dst` (necessarily <= `dstCapacity) * or an error code, which can be tested using ZSTD_isError() */ diff --git a/lib/zstd.h b/lib/zstd.h index 31e24771aec..6940a14171f 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -3099,8 +3099,12 @@ size_t ZSTD_compressBegin_usingCDict_advanced(ZSTD_CCtx* const cctx, const ZSTD_ as long as the encoder and decoder progress in "lock-step", aka use exactly the same buffer sizes, break contiguity at the same place, etc. - Once buffers are setup, start decompression, with ZSTD_decompressBegin(). + Once buffers are set up and decompression parameters are configured, initialize the decoder with ZSTD_decompressBegin(). If decompression requires a dictionary, use ZSTD_decompressBegin_usingDict() or ZSTD_decompressBegin_usingDDict(). + One of these initialization functions must be called before the first ZSTD_decompressContinue() call for every frame, + including when reusing a context or after calling ZSTD_DCtx_reset(). + ZSTD_DCtx_reset() does not replace this initialization, and ZSTD_decompressContinue() does not initialize the decoder itself. + After a session reset, an interrupted frame cannot be resumed : initialize the decoder again and restart from the frame's beginning. Then use ZSTD_nextSrcSizeToDecompress() and ZSTD_decompressContinue() alternatively. ZSTD_nextSrcSizeToDecompress() tells how many bytes to provide as 'srcSize' to ZSTD_decompressContinue(). @@ -3111,7 +3115,7 @@ size_t ZSTD_compressBegin_usingCDict_advanced(ZSTD_CCtx* const cctx, const ZSTD_ It can also be an error code, which can be tested with ZSTD_isError(). A frame is fully decoded when ZSTD_nextSrcSizeToDecompress() returns zero. - Context can then be reset to start a new decompression. + Before decoding another frame, call the appropriate ZSTD_decompressBegin*() function again. Note : it's possible to know if next input to present is a header or a block, using ZSTD_nextInputType(). This information is not required to properly decode a frame. @@ -3132,11 +3136,25 @@ size_t ZSTD_compressBegin_usingCDict_advanced(ZSTD_CCtx* const cctx, const ZSTD_ ZSTDLIB_STATIC_API size_t ZSTD_decodingBufferSize_min(unsigned long long windowSize, unsigned long long frameContentSize); /**< when frame content size is not known, pass in frameContentSize == ZSTD_CONTENTSIZE_UNKNOWN */ +/*! ZSTD_decompressBegin() : + * Initialize the bufferless decoder to start a new frame without a dictionary. + * Decompression parameters must be configured before calling this function. + * Call this function, ZSTD_decompressBegin_usingDict(), or ZSTD_decompressBegin_usingDDict() + * before the first ZSTD_decompressContinue() call for each frame, including after ZSTD_DCtx_reset(). + * Use the dictionary-taking variants when the frame requires a dictionary. + */ ZSTDLIB_STATIC_API size_t ZSTD_decompressBegin(ZSTD_DCtx* dctx); ZSTDLIB_STATIC_API size_t ZSTD_decompressBegin_usingDict(ZSTD_DCtx* dctx, const void* dict, size_t dictSize); ZSTDLIB_STATIC_API size_t ZSTD_decompressBegin_usingDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict); ZSTDLIB_STATIC_API size_t ZSTD_nextSrcSizeToDecompress(ZSTD_DCtx* dctx); + +/*! ZSTD_decompressContinue() : + * Decode the next part of a frame initialized with ZSTD_decompressBegin() + * or one of its dictionary-taking variants. This function does not initialize the decoder. + * After finishing a frame or resetting the session, initialize the decoder again + * before calling this function for a new frame. + */ ZSTDLIB_STATIC_API size_t ZSTD_decompressContinue(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize); /* misc */ From 729891c8e51d37723760d7d8c7568343cadfea96 Mon Sep 17 00:00:00 2001 From: Saleem Malik Date: Wed, 22 Jul 2026 12:53:40 +0530 Subject: [PATCH 55/94] fix off-by-one bounds check in ZSTD_seekTable_getFrameDecompressedSize --- contrib/seekable_format/tests/seekable_tests.c | 4 ++++ contrib/seekable_format/zstdseek_decompress.c | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index 5fd9a51a80c..cc4c83295b9 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -122,6 +122,10 @@ int main(int argc, const char** argv) size_t const origSize = ZSTD_seekTable_getFrameDecompressedSize(zst, 0); assert(origSize == inSize); + /* out-of-range frame index must be rejected, not read past the table */ + assert(ZSTD_isError(ZSTD_seekTable_getFrameCompressedSize(zst, nbFrames))); + assert(ZSTD_isError(ZSTD_seekTable_getFrameDecompressedSize(zst, nbFrames))); + unsigned const fo1idx = ZSTD_seekTable_offsetToFrameIndex(zst, 1); assert(fo1idx == 0); diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index 352fbb5e863..e181d553a61 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -366,7 +366,7 @@ size_t ZSTD_seekable_getFrameDecompressedSize(const ZSTD_seekable* zs, unsigned size_t ZSTD_seekTable_getFrameDecompressedSize(const ZSTD_seekTable* st, unsigned frameIndex) { - if (frameIndex > st->tableLen) return ERROR(frameIndex_tooLarge); + if (frameIndex >= st->tableLen) return ERROR(frameIndex_tooLarge); return st->entries[frameIndex + 1].dOffset - st->entries[frameIndex].dOffset; } From 9773d8d29ac430aa083cc1d4a7c9802e855805ce Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 11:26:23 -0800 Subject: [PATCH 56/94] ci: run the jobs that fail on ubuntu-26.04 on ubuntu-24.04 ubuntu-latest moved to ubuntu-26.04 on 2026-11-12. 19 jobs fail there for reasons unrelated to the code: no gold linker, no qemu-user-static package, the powerpc cross toolchain, old versions that do not compile in C23, and a larger libzstd.so. Only these jobs are pinned to ubuntu-24.04, each with its reason, until they are fixed for ubuntu-26.04. The other jobs stay on ubuntu-latest. --- .github/workflows/commit.yml | 2 +- .github/workflows/dev-long-tests.yml | 30 +++++++++++++-------------- .github/workflows/dev-short-tests.yml | 6 +++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 4d2a8bfa22b..ebad92f9e1a 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -34,7 +34,7 @@ jobs: make cxxtest; make clean short-tests-1: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # fails on ubuntu-26.04: powerpc cross toolchain cannot find -lgcc services: docker: image: fbopensource/zstd-circleci-primary:0.0.1 diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index 9b5ab884fc7..6925d5969e9 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -76,14 +76,14 @@ jobs: # lasts ~8mn tsan-zstreamtest: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: thread sanitizer zstreamtest run: CC=clang ZSTREAM_TESTTIME=-T3mn make tsan-test-zstream uasan-zstreamtest: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: ub + address sanitizer on zstreamtest @@ -91,7 +91,7 @@ jobs: # lasts ~5mn tsan-fuzztest: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: thread sanitizer fuzztest @@ -109,7 +109,7 @@ jobs: # lasts ~8mn gcc-8-asan-ubsan-testzstd: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: gcc-8 + ASan + UBSan + Test Zstd @@ -121,14 +121,14 @@ jobs: CC=gcc-8 make -j uasan-test-zstd Date: Thu, 12 Nov 2026 09:00:05 -0800 Subject: [PATCH 57/94] compress: drop pending stable input when the session is reset With ZSTD_c_stableInBuffer, ZSTD_compressStream2() with ZSTD_e_continue can leave less than one block of input pending, counted in stableIn_notConsumed, before or after compression has started. ZSTD_CCtx_reset() did not clear it, so after an abandoned frame, the next frame started that many bytes before the new input buffer: it read out of bounds, and the frame contained those bytes. ZSTD_compress2() was affected too, since it starts with a session reset. Fixes #4868 --- lib/compress/zstd_compress.c | 1 + tests/zstreamtest.c | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 82d08208fa4..44dcd0ffc57 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -1411,6 +1411,7 @@ size_t ZSTD_CCtx_reset(ZSTD_CCtx* cctx, ZSTD_ResetDirective reset) #endif cctx->streamStage = zcss_init; cctx->pledgedSrcSizePlusOne = 0; + cctx->stableIn_notConsumed = 0; /* input pending in the abandoned frame */ } if ( (reset == ZSTD_reset_parameters) || (reset == ZSTD_reset_session_and_parameters) ) { diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index b303499504c..fc3e98a6d1a 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -1043,6 +1043,51 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_c_stableInBuffer : a new session ignores input pending in the abandoned frame : ", testNb++); + { ZSTD_CCtx* const cctx2 = ZSTD_createCCtx(); + const char* const src2 = (const char*)CNBuffer + 1 MB; + size_t const srcSize2 = 50 KB; + int pendingCase; + assert(cctx2 != NULL); + /* 0: pending before initialization, 1: pending within a frame, + * 2: multi-threaded, 3: new session started by ZSTD_compress2() */ + for (pendingCase = 0; pendingCase < 4; pendingCase++) { + ZSTD_inBuffer inBuf; + ZSTD_outBuffer outBuf; + size_t frameSize; + inBuf.src = CNBuffer; + inBuf.size = (pendingCase == 1) ? 200 KB : 1 KB; + inBuf.pos = 0; + outBuf.dst = compressedBuffer; + outBuf.size = compressedBufferSize; + outBuf.pos = 0; + CHECK_Z( ZSTD_CCtx_reset(cctx2, ZSTD_reset_session_and_parameters) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx2, ZSTD_c_stableInBuffer, 1) ); + if (pendingCase == 2) CHECK_Z( ZSTD_CCtx_setParameter(cctx2, ZSTD_c_nbWorkers, 2) ); + CHECK_Z( ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_continue) ); + if (pendingCase == 3) { + frameSize = ZSTD_compress2(cctx2, compressedBuffer, compressedBufferSize, src2, srcSize2); + CHECK_Z(frameSize); + } else { + CHECK_Z( ZSTD_CCtx_reset(cctx2, ZSTD_reset_session_only) ); + inBuf.src = src2; + inBuf.size = srcSize2; + inBuf.pos = 0; + outBuf.pos = 0; + /* unknown size : keeps multi-threading enabled */ + if (pendingCase == 2) CHECK_Z( ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_flush) ); + CHECK(ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_end) != 0, "compression should be successful and fully flushed"); + frameSize = outBuf.pos; + } + { size_t const decSize = ZSTD_decompress(decodedBuffer, CNBufferSize, compressedBuffer, frameSize); + CHECK_Z(decSize); + CHECK(decSize != srcSize2, "case %i : regenerated %u bytes, instead of %u", pendingCase, (unsigned)decSize, (unsigned)srcSize2); + CHECK(memcmp(src2, decodedBuffer, srcSize2) != 0, "case %i : regenerated data different from original", pendingCase); + } } + ZSTD_freeCCtx(cctx2); + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_compressStream2() with ZSTD_c_stableOutBuffer only : ", testNb++); CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 1)); From 35eb4e6913bd4c85331d1f11dff9d3055ac8e12e Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 09:00:14 -0800 Subject: [PATCH 58/94] compress: check pending stable input when compression starts The stable input buffer was checked against the pending input only when the call kept the input pending. A call that started compression, with ZSTD_e_flush, ZSTD_e_end or a full block of input, skipped the check. A different src pointer then made compression read before it, instead of failing with stabilityCondition_notRespected as documented. Disabling ZSTD_c_stableInBuffer while input is pending is now rejected too: that input was silently missing from the frame. --- lib/compress/zstd_compress.c | 11 ++++++----- tests/zstreamtest.c | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 44dcd0ffc57..1f37429d244 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -6575,14 +6575,15 @@ size_t ZSTD_compressStream2( ZSTD_CCtx* cctx, if (cctx->streamStage == zcss_init) { size_t const inputSize = input->size - input->pos; /* no obligation to start from pos==0 */ size_t const totalInputSize = inputSize + cctx->stableIn_notConsumed; + if (cctx->stableIn_notConsumed) { /* not the first time */ + /* check stable source guarantees, including when this call starts compression */ + RETURN_ERROR_IF(cctx->requestedParams.inBufferMode != ZSTD_bm_stable, stabilityCondition_notRespected, "stableInBuffer condition not respected: disabled while input is pending"); + RETURN_ERROR_IF(input->src != cctx->expectedInBuffer.src, stabilityCondition_notRespected, "stableInBuffer condition not respected: wrong src pointer"); + RETURN_ERROR_IF(input->pos != cctx->expectedInBuffer.size, stabilityCondition_notRespected, "stableInBuffer condition not respected: externally modified pos"); + } if ( (cctx->requestedParams.inBufferMode == ZSTD_bm_stable) /* input is presumed stable, across invocations */ && (endOp == ZSTD_e_continue) /* no flush requested, more input to come */ && (totalInputSize < ZSTD_BLOCKSIZE_MAX) ) { /* not even reached one block yet */ - if (cctx->stableIn_notConsumed) { /* not the first time */ - /* check stable source guarantees */ - RETURN_ERROR_IF(input->src != cctx->expectedInBuffer.src, stabilityCondition_notRespected, "stableInBuffer condition not respected: wrong src pointer"); - RETURN_ERROR_IF(input->pos != cctx->expectedInBuffer.size, stabilityCondition_notRespected, "stableInBuffer condition not respected: externally modified pos"); - } /* pretend input was consumed, to give a sense forward progress */ input->pos = input->size; /* save stable inBuffer, for later control, and flush/end */ diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index fc3e98a6d1a..751f5ac603c 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -1088,6 +1088,38 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_c_stableInBuffer : pending input is checked when compression starts : ", testNb++); + { ZSTD_CCtx* const cctx2 = ZSTD_createCCtx(); + int violation; + assert(cctx2 != NULL); + /* 0: different src pointer, 1: ZSTD_c_stableInBuffer disabled */ + for (violation = 0; violation < 2; violation++) { + ZSTD_inBuffer inBuf; + ZSTD_outBuffer outBuf; + inBuf.src = CNBuffer; + inBuf.size = 1 KB; + inBuf.pos = 0; + outBuf.dst = compressedBuffer; + outBuf.size = compressedBufferSize; + outBuf.pos = 0; + CHECK_Z( ZSTD_CCtx_reset(cctx2, ZSTD_reset_session_and_parameters) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx2, ZSTD_c_stableInBuffer, 1) ); + CHECK_Z( ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_continue) ); + if (violation == 0) { + inBuf.src = (const char*)CNBuffer + 1 MB; + inBuf.pos = 0; + } else { + CHECK_Z( ZSTD_CCtx_setParameter(cctx2, ZSTD_c_stableInBuffer, 0) ); + inBuf.size = 2 KB; + } + { size_t const ret = ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_end); + CHECK(!ZSTD_isError(ret), "case %i : must error", violation); + CHECK(!(ZSTD_getErrorCode(ret) == ZSTD_error_stabilityCondition_notRespected), "case %i : must be this error", violation); + } } + ZSTD_freeCCtx(cctx2); + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_compressStream2() with ZSTD_c_stableOutBuffer only : ", testNb++); CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 1)); From c55665add17aa00cf2c3d5430577fe29802c07f2 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 09:00:14 -0800 Subject: [PATCH 59/94] compress: let ZSTD_flushStream() and ZSTD_endStream() resume pending stable input While input is pending before the first block, the parameters of the frame are not applied yet. inBuffer_forEndFlush() relied on them, so on a context whose previous frame did not use ZSTD_c_stableInBuffer, such as a new one, it passed an empty input instead of the stable buffer, and the frame failed with srcSize_wrong. --- lib/compress/zstd_compress.c | 3 ++- tests/zstreamtest.c | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c index 1f37429d244..0d06a6ff256 100644 --- a/lib/compress/zstd_compress.c +++ b/lib/compress/zstd_compress.c @@ -8258,7 +8258,8 @@ ZSTD_compressSequencesAndLiterals(ZSTD_CCtx* cctx, static ZSTD_inBuffer inBuffer_forEndFlush(const ZSTD_CStream* zcs) { const ZSTD_inBuffer nullInput = { NULL, 0, 0 }; - const int stableInput = (zcs->appliedParams.inBufferMode == ZSTD_bm_stable); + const int stableInput = (zcs->appliedParams.inBufferMode == ZSTD_bm_stable) + || (zcs->stableIn_notConsumed > 0); /* pending input, parameters not applied yet */ return stableInput ? zcs->expectedInBuffer : nullInput; } diff --git a/tests/zstreamtest.c b/tests/zstreamtest.c index 751f5ac603c..bc486574721 100644 --- a/tests/zstreamtest.c +++ b/tests/zstreamtest.c @@ -1120,6 +1120,30 @@ static int basicUnitTests(U32 seed, double compressibility, int bigTests) } DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_c_stableInBuffer : ZSTD_endStream() on pending input, new context : ", testNb++); + { ZSTD_CCtx* const cctx2 = ZSTD_createCCtx(); + ZSTD_inBuffer inBuf; + ZSTD_outBuffer outBuf; + size_t const srcSize2 = 1 KB; + assert(cctx2 != NULL); + inBuf.src = CNBuffer; + inBuf.size = srcSize2; + inBuf.pos = 0; + outBuf.dst = compressedBuffer; + outBuf.size = compressedBufferSize; + outBuf.pos = 0; + CHECK_Z( ZSTD_CCtx_setParameter(cctx2, ZSTD_c_stableInBuffer, 1) ); + CHECK_Z( ZSTD_compressStream2(cctx2, &outBuf, &inBuf, ZSTD_e_continue) ); + CHECK(ZSTD_endStream(cctx2, &outBuf) != 0, "compression should be successful and fully flushed"); + { size_t const decSize = ZSTD_decompress(decodedBuffer, CNBufferSize, compressedBuffer, outBuf.pos); + CHECK_Z(decSize); + CHECK(decSize != srcSize2, "regenerated %u bytes, instead of %u", (unsigned)decSize, (unsigned)srcSize2); + CHECK(memcmp(CNBuffer, decodedBuffer, srcSize2) != 0, "regenerated data different from original"); + } + ZSTD_freeCCtx(cctx2); + } + DISPLAYLEVEL(3, "OK \n"); + DISPLAYLEVEL(3, "test%3i : ZSTD_compressStream2() with ZSTD_c_stableOutBuffer only : ", testNb++); CHECK_Z(ZSTD_CCtx_reset(cctx, ZSTD_reset_session_and_parameters)); CHECK_Z(ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 1)); From b09d9aa6b5535834d211daf1df3e67fd75b42ea1 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 12 Nov 2026 15:00:17 -0800 Subject: [PATCH 60/94] ci: Run ASAN CLI tests on ubuntu-24.04 --- .github/workflows/dev-long-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index 151708b011d..c561dd71ca8 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -128,7 +128,7 @@ jobs: run: CC=clang make -j uasan-test-zstd Date: Thu, 12 Nov 2026 10:43:13 -0800 Subject: [PATCH 61/94] seekable_format: Reject mismatched frame sizes Prevent malformed seek tables from repeatedly decoding the same frame with FILE or custom sources. --- .../seekable_format/tests/seekable_tests.c | 72 +++++++++++++++++++ contrib/seekable_format/zstdseek_decompress.c | 9 +++ 2 files changed, 81 insertions(+) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index cc4c83295b9..20e733b8716 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -7,6 +7,7 @@ #include "../zstd_seekable.h" #include "zstd_errors.h" +#include "xxhash.h" /* ZSTD_seekable_customFile implementation that reads/seeks a buffer while keeping track of total bytes read */ @@ -448,6 +449,77 @@ int main(int argc, const char** argv) } printf("Success!\n"); + printf("Test %u - reject seek-table decompressed-size mismatches: ", testNb++); + { unsigned const actualSizes[] = {1, 0}; + unsigned checksumFlag; + for (checksumFlag = 0; checksumFlag < 2; ++checksumFlag) { + size_t frameCase; + for (frameCase = 0; frameCase < sizeof(actualSizes) / sizeof(actualSizes[0]); ++frameCase) { + unsigned const actualSize = actualSizes[frameCase]; + unsigned extraSize; + for (extraSize = 0; extraSize < 2; ++extraSize) { + char const data[] = "A"; + unsigned char archive[128]; + unsigned const declaredSize = actualSize + extraSize; + size_t const cSize = ZSTD_compress(archive, sizeof(archive), data, actualSize, 1); + ZSTD_frameLog* const log = ZSTD_seekable_createFrameLog((int)checksumFlag); + ZSTD_outBuffer seekTable = {archive, sizeof(archive), cSize}; + unsigned sourceMode; + assert(!ZSTD_isError(cSize)); + assert(log != NULL); + { size_t const status = ZSTD_seekable_logFrame(log, (unsigned)cSize, declaredSize, + (unsigned)XXH64(data, actualSize, 0)); + assert(!ZSTD_isError(status)); + } + { size_t const status = ZSTD_seekable_writeSeekTable(log, &seekTable); + assert(status == 0); + } + ZSTD_seekable_freeFrameLog(log); + + /* The frame and checksum are valid; only its advertised + * decompressed size is wrong when extraSize is 1. */ + for (sourceMode = 0; sourceMode < 3; ++sourceMode) { + ZSTD_seekable* const stream = ZSTD_seekable_create(); + buffWrapperWithTotal_t buff = {archive, seekTable.pos, 0, 0}; + FILE* file = NULL; + char out[2]; + size_t initStatus; + unsigned attempt; + int failed; + assert(stream != NULL); + if (sourceMode == 0) { + file = tmpfile(); + if (file == NULL || fwrite(archive, 1, seekTable.pos, file) != seekTable.pos || fflush(file) != 0) { + if (file != NULL) fclose(file); + ZSTD_seekable_free(stream); + goto _test_error; + } + initStatus = ZSTD_seekable_initFile(stream, file); + } else if (sourceMode == 1) { + initStatus = ZSTD_seekable_initBuff(stream, archive, seekTable.pos); + } else { + ZSTD_seekable_customFile const src = {&buff, &readBuffWithTotal, &seekBuffWithTotal}; + initStatus = ZSTD_seekable_initAdvanced(stream, src); + } + failed = ZSTD_isError(initStatus) != 0; + for (attempt = 0; !failed && attempt < 2; ++attempt) { + size_t const status = ZSTD_seekable_decompress(stream, out, declaredSize, 0); + if (extraSize) { + failed = ZSTD_getErrorCode(status) != ZSTD_error_corruption_detected; + } else { + failed = status != actualSize || memcmp(out, data, actualSize) != 0; + } + } + ZSTD_seekable_free(stream); + if (file != NULL) fclose(file); + if (failed) goto _test_error; + } + } + } + } + } + printf("Success!\n"); + /* TODO: Add more tests */ printf("Finished tests\n"); return 0; diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index e181d553a61..498bd3d188f 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -560,6 +560,15 @@ size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsign if (toRead == 0) { /* frame complete */ + /* A short frame must not cause the same seek-table entry to + * be selected and decompressed again. Check the declared + * boundary for every source, including FILE and custom I/O. */ + assert(targetFrame < zs->seekTable.tableLen); + if (zs->decompressedOffset != zs->seekTable.entries[targetFrame + 1].dOffset) { + zs->curFrame = (U32)-1; + return ERROR(corruption_detected); + } + /* verify checksum */ if (zs->seekTable.checksumFlag && (XXH64_digest(&zs->xxhState) & 0xFFFFFFFFU) != From 2ad0cb989b7742124f005bfc7956c851bd4fee45 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 12 Nov 2026 08:12:21 -0800 Subject: [PATCH 62/94] seekable_format: Expand frame-index bounds regression coverage Stack additional tests and API documentation on the existing off-by-one fix. Check both seekable and standalone seek-table size accessors, exact frameIndex_tooLarge errors, larger invalid indices, and empty seek tables. --- .../seekable_format/tests/seekable_tests.c | 37 +++++++++++++++++++ contrib/seekable_format/zstd_seekable.h | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index 20e733b8716..9165b03babf 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -1,5 +1,6 @@ #include #include +#include #include // malloc #include #include @@ -55,6 +56,29 @@ static int seekBuffWithTotal(void* opaque, long long offset, int origin) return 0; } +static int testFrameSizeBounds(const ZSTD_seekable* stream, const ZSTD_seekTable* table) +{ + unsigned const nbFrames = ZSTD_seekTable_getNumFrames(table); + unsigned const indices[] = {nbFrames, nbFrames + 1, UINT_MAX}; + size_t i; + for (i = 0; i < sizeof(indices) / sizeof(indices[0]); ++i) { + unsigned const index = indices[i]; + size_t const results[] = { + ZSTD_seekable_getFrameCompressedSize(stream, index), + ZSTD_seekable_getFrameDecompressedSize(stream, index), + ZSTD_seekTable_getFrameCompressedSize(table, index), + ZSTD_seekTable_getFrameDecompressedSize(table, index), + }; + size_t j; + for (j = 0; j < sizeof(results) / sizeof(results[0]); ++j) { + if (ZSTD_getErrorCode(results[j]) != ZSTD_error_frameIndex_tooLarge) { + return 1; + } + } + } + return 0; +} + /* Basic unit tests for zstd seekable format */ int main(int argc, const char** argv) { @@ -130,12 +154,14 @@ int main(int argc, const char** argv) unsigned const fo1idx = ZSTD_seekTable_offsetToFrameIndex(zst, 1); assert(fo1idx == 0); + int const boundsStatus = testFrameSizeBounds(stream, zst); free(inBuffer); free(seekBuffer); free(outBuffer); ZSTD_seekable_freeCStream(zscs); ZSTD_seekTable_free(zst); ZSTD_seekable_free(stream); + if (boundsStatus != 0) goto _test_error; } printf("Success!\n"); @@ -173,6 +199,17 @@ int main(int argc, const char** argv) goto _test_error; } } + /* This valid seek table has no frames, so even index 0 is invalid. */ + { ZSTD_seekTable* const table = ZSTD_seekTable_create_fromSeekable(stream); + assert(table != NULL); + assert(ZSTD_seekTable_getNumFrames(table) == 0); + int const boundsStatus = testFrameSizeBounds(stream, table); + ZSTD_seekTable_free(table); + if (boundsStatus != 0) { + ZSTD_seekable_free(stream); + goto _test_error; + } } + /* Should return an error, but not hang */ { const size_t offset = 2; size_t const status = ZSTD_seekable_decompress(stream, uncompressed_data, uncompressed_size, offset); diff --git a/contrib/seekable_format/zstd_seekable.h b/contrib/seekable_format/zstd_seekable.h index e24e4e33edd..5449fd9292b 100644 --- a/contrib/seekable_format/zstd_seekable.h +++ b/contrib/seekable_format/zstd_seekable.h @@ -157,7 +157,7 @@ ZSTDLIB_API size_t ZSTD_seekable_writeSeekTable(ZSTD_frameLog* fl, ZSTD_outBuffe * An `offset` past the end of the data is an error. * * The seek table access functions can be used to obtain the data contained -* in the seek table. If frameIndex is larger than the value returned by +* in the seek table. If frameIndex is greater than or equal to * ZSTD_seekable_getNumFrames(), they will return error codes checkable with * ZSTD_isError(). Note that since the offset access functions return * unsigned long long instead of size_t, in this case they will instead return From e34256124fef590da2e0c9e9a3a604a822d18866 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:05:45 -0800 Subject: [PATCH 63/94] decompress: distinguish raw DDicts from empty hash slots Terminate multi-DDict lookup at NULL entries, not dictionary ID zero. Cover missing and registered IDs that collide with a raw-content dictionary, using distinct dictionary contents. --- lib/decompress/zstd_decompress.c | 15 ++-- tests/fuzzer.c | 150 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 2eacef7bf37..8bf209398a3 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -157,14 +157,11 @@ static const ZSTD_DDict* ZSTD_DDictHashSet_getDDict(ZSTD_DDictHashSet* hashSet, size_t idx = ZSTD_DDictHashSet_getIndex(hashSet, dictID); const size_t idxRangeMask = hashSet->ddictPtrTableSize - 1; DEBUGLOG(4, "Hashed index: for dictID: %u is %zu", dictID, idx); - for (;;) { - size_t currDictID = ZSTD_getDictID_fromDDict(hashSet->ddictPtrTable[idx]); - if (currDictID == dictID || currDictID == 0) { - /* currDictID == 0 implies a NULL ddict entry */ + while (hashSet->ddictPtrTable[idx] != NULL) { + if (ZSTD_getDictID_fromDDict(hashSet->ddictPtrTable[idx]) == dictID) break; - } else { - idx = (idx + 1) & idxRangeMask; /* Goes to start of table when we reach the end */ - } + /* ID 0 is valid for raw-content dictionaries, not an empty slot. */ + idx = (idx + 1) & idxRangeMask; } DEBUGLOG(4, "Final idx after probing for dictID %u is: %zu", dictID, idx); return hashSet->ddictPtrTable[idx]; @@ -2156,7 +2153,9 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB } } #endif { size_t const hSize = ZSTD_getFrameHeader_advanced(&zds->fParams, zds->headerBuffer, zds->lhSize, zds->format); - if (zds->refMultipleDDicts && zds->ddictSet) { + /* Only a complete ordinary frame header can select a DDict. */ + if (hSize == 0 && zds->fParams.frameType == ZSTD_frame + && zds->refMultipleDDicts && zds->ddictSet) { ZSTD_DCtx_selectFrameDDict(zds); } if (ZSTD_isError(hSize)) { diff --git a/tests/fuzzer.c b/tests/fuzzer.c index b3e0d6a6473..6b128513d8c 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1513,6 +1513,154 @@ static void test_largeDictionaryRepcodes(const void* dict, size_t dictSize) free(largeDict); } +/* Multi-DDict fixtures deliberately share entropy tables but have different + * content. The handcrafted frame always copies eight dictionary bytes: unlike + * compressor-generated data, it cannot turn into a literals-only block and + * accidentally pass when the decoder selects the wrong dictionary. */ +enum { + FUZ_mdict_knownSize = 1, + FUZ_mdict_checksum = 2 +}; + +static void FUZ_mdict_makeDict(BYTE dst[44], U32 id, int fill) +{ + static const BYTE header[28] = { + 0x37,0xa4,0x30,0xec, 0,0,0,0, /* magic and dictionary ID */ + 128,0x10, /* Huffman table */ + 0xf0,0x03, 0xf0,0x03, 0xf0,0x03, /* FSE tables */ + 4,0,0,0, 4,0,0,0, 4,0,0,0 /* repeat offsets */ + }; + memcpy(dst, header, sizeof(header)); + MEM_writeLE32(dst + 4, id); + memset(dst + sizeof(header), fill, 16); +} + +/* ID 0 creates a raw-content DDict; other IDs create formatted DDicts. */ +static ZSTD_DDict* FUZ_mdict_createDict(U32 id, int fill) +{ + BYTE bytes[44]; + ZSTD_DDict* ddict; + FUZ_mdict_makeDict(bytes, id, fill); + ddict = id ? ZSTD_createDDict(bytes, sizeof(bytes)) + : ZSTD_createDDict(bytes + 28, 16); + CHECK(ddict != NULL && ZSTD_getDictID_fromDDict(ddict) == id); + return ddict; +} + +/* One compressed block, no literals, one match of length 8 at offset 4. + * ID 0 omits the frame's dictID. A known content size permits the streaming + * shortcut; the optional checksum is for the expected output "AAAAAAAA". */ +static size_t FUZ_mdict_makeFrame(BYTE* dst, U32 id, unsigned flags) +{ + static const BYTE block[] = { + 0x3d,0,0, /* last compressed block, seven-byte payload */ + 0,1,0x54, /* no literals; one sequence; RLE sequence tables */ + 0,0,5,1 /* LL=0, repeat offset, ML=8, bitstream */ + }; + int const knownSize = (flags & FUZ_mdict_knownSize) != 0; + size_t n = 4; + MEM_writeLE32(dst, ZSTD_MAGICNUMBER); + dst[n++] = (BYTE)((id ? 3 : 0) | (knownSize ? 32 : 0) + | ((flags & FUZ_mdict_checksum) ? 4 : 0)); + if (!knownSize) dst[n++] = 0; /* 1 KiB window */ + if (id) { MEM_writeLE32(dst + n, id); n += 4; } + if (knownSize) dst[n++] = 8; + memcpy(dst + n, block, sizeof(block)); n += sizeof(block); + if (flags & FUZ_mdict_checksum) { + MEM_writeLE32(dst + n, (U32)XXH64("AAAAAAAA", 8, 0)); n += 4; + } + return n; +} + +/* The second DDict, when supplied, is initially active. Both remain registered. */ +static ZSTD_DCtx* FUZ_mdict_createContext(const ZSTD_DDict* first, + const ZSTD_DDict* second) +{ + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + CHECK(dctx != NULL); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1)); + if (first) CHECK_Z(ZSTD_DCtx_refDDict(dctx, first)); + if (second) CHECK_Z(ZSTD_DCtx_refDDict(dctx, second)); + return dctx; +} + +/* Feed complete frames in chunks, returning the first error or total output. + * These tests provide enough output space, so every call must make progress. */ +static size_t FUZ_mdict_decompressStream(ZSTD_DCtx* dctx, void* dst, size_t capacity, + const BYTE* src, size_t size, size_t chunk) +{ + ZSTD_outBuffer out = {dst, capacity, 0}; + size_t pos = 0, ret = 1; + CHECK(chunk != 0); + while (pos < size) { + size_t const n = MIN(size - pos, chunk); + ZSTD_inBuffer in = {src + pos, n, 0}; + while (in.pos < in.size) { + size_t const oldIn = in.pos, oldOut = out.pos; + ret = ZSTD_decompressStream(dctx, &out, &in); + if (ZSTD_isError(ret)) return ret; + CHECK(in.pos > oldIn || out.pos > oldOut); + } + pos += n; + } + CHECK_EQ(ret, 0); + return out.pos; +} + +static void FUZ_mdict_checkOutput(size_t r, const void* out, const char* expected) +{ + CHECK_Z(r); + CHECK_EQ(r, strlen(expected)); + CHECK_EQ(memcmp(out, expected, r), 0); +} + +static void FUZ_mdict_testZeroID(void) +{ + U32 const zero = 0; + U32 id = 1; + ZSTD_DDict* const raw = FUZ_mdict_createDict(0, 'R'); + ZSTD_DDict* full; + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(raw, NULL); + BYTE frame[32], out[32]; + size_t n, r; + /* Find a nonzero ID in raw dictionary 0's initial 64-slot hash bucket. */ + while ((XXH64(&id, sizeof(id), 0) & 63) != (XXH64(&zero, sizeof(zero), 0) & 63)) + ++id; + full = FUZ_mdict_createDict(id, 'A'); + n = FUZ_mdict_makeFrame(frame, id, 0); + + /* An occupied ID-0 slot is not a match for the missing nonzero ID. */ + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + + /* After insertion, lookup must probe past raw dictionary 0 to find A. + * An actual ID-less frame must still find the raw dictionary itself. */ + CHECK_Z(ZSTD_DCtx_refDDict(dctx, full)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, raw)); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + n = FUZ_mdict_makeFrame(frame, 0, 0); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); + FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + CHECK_Z(ZSTD_freeDDict(raw)); CHECK_Z(ZSTD_freeDDict(full)); +} + +static unsigned test_multipleDDicts(unsigned testNb) +{ + static const struct { const char* name; void (*run)(void); } tests[] = { + {"raw dictionary ID zero", FUZ_mdict_testZeroID}, + }; + size_t i; + for (i = 0; i < COUNTOF(tests); ++i) { + DISPLAYLEVEL(3, "test%3u : multi-DDict %s : ", testNb++, tests[i].name); + tests[i].run(); + DISPLAYLEVEL(3, "OK \n"); + } + return testNb; +} + static int basicUnitTests(U32 const seed, double compressibility) { size_t const CNBuffSize = 5 MB; @@ -4453,6 +4601,8 @@ static int basicUnitTests(U32 const seed, double compressibility) } DISPLAYLEVEL(3, "OK \n"); + testNb = test_multipleDDicts(testNb); + DISPLAYLEVEL(3, "test%3i : ZSTD_decompressDCtx() with multiple ddicts : ", testNb++); { const size_t numDicts = 128; From f8936f645e96e1bcd8efa7e3c5df3c35c67b29ca Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:06:14 -0800 Subject: [PATCH 64/94] decompress: grow the DDict hash table at the intended load factor Compare scaled occupancy directly so the table grows at 75% instead of 25%. Count allocations and verify buffered-streaming lookup after rehashing, independently of the one-shot decoder changes. --- lib/decompress/zstd_decompress.c | 4 ++-- tests/fuzzer.c | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 8bf209398a3..060efcde0ce 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -83,7 +83,7 @@ #define DDICT_HASHSET_MAX_LOAD_FACTOR_COUNT_MULT 4 #define DDICT_HASHSET_MAX_LOAD_FACTOR_SIZE_MULT 3 /* These two constants represent SIZE_MULT/COUNT_MULT load factor without using a float. * Currently, that means a 0.75 load factor. - * So, if count * COUNT_MULT / size * SIZE_MULT != 0, then we've exceeded + * So, if count * COUNT_MULT >= size * SIZE_MULT, then we've reached * the load factor of the ddict hash set. */ @@ -204,7 +204,7 @@ static void ZSTD_freeDDictHashSet(ZSTD_DDictHashSet* hashSet, ZSTD_customMem cus */ static size_t ZSTD_DDictHashSet_addDDict(ZSTD_DDictHashSet* hashSet, const ZSTD_DDict* ddict, ZSTD_customMem customMem) { DEBUGLOG(4, "Adding dict ID: %u to hashset with - Count: %zu Tablesize: %zu", ZSTD_getDictID_fromDDict(ddict), hashSet->ddictPtrCount, hashSet->ddictPtrTableSize); - if (hashSet->ddictPtrCount * DDICT_HASHSET_MAX_LOAD_FACTOR_COUNT_MULT / hashSet->ddictPtrTableSize * DDICT_HASHSET_MAX_LOAD_FACTOR_SIZE_MULT != 0) { + if (hashSet->ddictPtrCount * DDICT_HASHSET_MAX_LOAD_FACTOR_COUNT_MULT >= hashSet->ddictPtrTableSize * DDICT_HASHSET_MAX_LOAD_FACTOR_SIZE_MULT) { FORWARD_IF_ERROR(ZSTD_DDictHashSet_expand(hashSet, customMem), ""); } FORWARD_IF_ERROR(ZSTD_DDictHashSet_emplaceDDict(hashSet, ddict), ""); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 6b128513d8c..51b578ad51a 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1647,10 +1647,50 @@ static void FUZ_mdict_testZeroID(void) CHECK_Z(ZSTD_freeDDict(raw)); CHECK_Z(ZSTD_freeDDict(full)); } +/* The DDicts use the default allocator. Counting the DCtx's first 128-slot + * table lets the test observe growth without inspecting private structs. */ +static void* FUZ_mdict_alloc(void* opaque, size_t size) +{ + if (size == 128 * sizeof(ZSTD_DDict*)) ++*(unsigned*)opaque; + return malloc(size); +} + +static void FUZ_mdict_free(void* opaque, void* ptr) +{ + (void)opaque; + free(ptr); +} + +static void FUZ_mdict_testGrowth(void) +{ + unsigned expansions = 0; + ZSTD_customMem const mem = {FUZ_mdict_alloc, FUZ_mdict_free, &expansions}; + ZSTD_DCtx* const dctx = ZSTD_createDCtx_advanced(mem); + ZSTD_DDict* ddicts[49]; + BYTE frame[32], out[32]; + size_t i, n, r; + CHECK(dctx != NULL); + expansions = 0; /* Do not count allocation of the DCtx itself. */ + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1)); + for (i = 0; i < COUNTOF(ddicts); ++i) { + ddicts[i] = FUZ_mdict_createDict(10000 + (U32)i, 'A' + (int)(i % 26)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, ddicts[i])); + /* A 64-slot table grows when inserting entry 49, after reaching 75%. + * The old expression incorrectly grew it when inserting entry 17. */ + CHECK_EQ(expansions, (unsigned)(i == 48)); + } + n = FUZ_mdict_makeFrame(frame, 10000, 0); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); /* Rehashing retained entry 1. */ + CHECK_Z(ZSTD_freeDCtx(dctx)); + for (i = 0; i < COUNTOF(ddicts); ++i) CHECK_Z(ZSTD_freeDDict(ddicts[i])); +} + static unsigned test_multipleDDicts(unsigned testNb) { static const struct { const char* name; void (*run)(void); } tests[] = { {"raw dictionary ID zero", FUZ_mdict_testZeroID}, + {"hash-table growth", FUZ_mdict_testGrowth}, }; size_t i; for (i = 0; i < COUNTOF(tests); ++i) { From 91f8d02e4f77d466af2152fc35eda3f3252f0cea Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:06:47 -0800 Subject: [PATCH 65/94] decompress: clear remaining multi-DDict references Drop the borrowed-reference table on empty dictionary loads and when disabling multi-DDict mode. Disabling a populated set also clears its selected dictionary, so callers can release every DDict. Reuse cleanup for reset and refDDict(NULL), document the lifetime rule, and test immediate release. --- CHANGELOG | 1 + lib/decompress/zstd_decompress.c | 32 ++++++++++++++++++---------- lib/zstd.h | 6 +++++- tests/fuzzer.c | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 17e09aae2f0..f2c32cda2ee 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ v1.6.0 (Dec 2025) fix: on a static DCtx, `ZSTD_DCtx_loadDictionary()` and `ZSTD_DCtx_refPrefix()` now return `ZSTD_error_memory_allocation` for nonempty dictionaries and prefixes instead of allocating and leaking an internal DDict api: legacy format support is now disabled by default +api: disabling experimental `ZSTD_d_refMultipleDDicts` releases every dictionary reference in its set; no selected dictionary is retained for subsequent decompression build: `ZSTD_LEGACY_SUPPORT` defaults to `0` in Makefile and CMake V1.5.7 (Feb 2025) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 060efcde0ce..aaa2d9bc356 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -319,6 +319,16 @@ static void ZSTD_clearDict(ZSTD_DCtx* dctx) dctx->dictUses = ZSTD_dont_use; } +/* Discard borrowed dictionary references without freeing the DDicts. */ +static void ZSTD_clearDDictSet(ZSTD_DCtx* dctx) +{ + if (dctx->ddictSet != NULL) { + assert(!dctx->staticSize); + ZSTD_freeDDictHashSet(dctx->ddictSet, dctx->customMem); + dctx->ddictSet = NULL; + } +} + size_t ZSTD_freeDCtx(ZSTD_DCtx* dctx) { if (dctx==NULL) return 0; /* support free on NULL */ @@ -331,10 +341,7 @@ size_t ZSTD_freeDCtx(ZSTD_DCtx* dctx) if (dctx->legacyContext) ZSTD_freeLegacyStreamContext(dctx->legacyContext, dctx->previousLegacyVersion); #endif - if (dctx->ddictSet) { - ZSTD_freeDDictHashSet(dctx->ddictSet, cMem); - dctx->ddictSet = NULL; - } + ZSTD_clearDDictSet(dctx); ZSTD_customFree(dctx, cMem); return 0; } @@ -1707,6 +1714,8 @@ size_t ZSTD_DCtx_loadDictionary_advanced(ZSTD_DCtx* dctx, { RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); + if (dict == NULL || dictSize == 0) + ZSTD_clearDDictSet(dctx); if (dict && dictSize != 0) { RETURN_ERROR_IF(dctx->staticSize, memory_allocation, "static DCtx can't allocate an internal DDict"); @@ -1800,9 +1809,8 @@ size_t ZSTD_DCtx_refDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) assert(!dctx->staticSize); /* Impossible: ddictSet cannot have been allocated if static dctx */ FORWARD_IF_ERROR(ZSTD_DDictHashSet_addDDict(dctx->ddictSet, ddict, dctx->customMem), ""); } - } else if (dctx->ddictSet) { - ZSTD_freeDDictHashSet(dctx->ddictSet, dctx->customMem); - dctx->ddictSet = NULL; + } else { + ZSTD_clearDDictSet(dctx); } return 0; } @@ -1938,6 +1946,11 @@ size_t ZSTD_DCtx_setParameter(ZSTD_DCtx* dctx, ZSTD_dParameter dParam, int value if (dctx->staticSize != 0) { RETURN_ERROR(parameter_unsupported, "Static dctx does not support multiple DDicts!"); } + if (value == ZSTD_rmd_refSingleDDict && dctx->ddictSet != NULL) { + /* No dictionary from the set is inherited by single-DDict mode. */ + ZSTD_clearDict(dctx); + ZSTD_clearDDictSet(dctx); + } dctx->refMultipleDDicts = (ZSTD_refMultipleDDicts_e)value; return 0; case ZSTD_d_disableHuffmanAssembly: @@ -1969,10 +1982,7 @@ size_t ZSTD_DCtx_reset(ZSTD_DCtx* dctx, ZSTD_ResetDirective reset) || (reset == ZSTD_reset_session_and_parameters) ) { RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); - if (dctx->ddictSet) { - ZSTD_freeDDictHashSet(dctx->ddictSet, dctx->customMem); - dctx->ddictSet = NULL; - } + ZSTD_clearDDictSet(dctx); ZSTD_DCtx_resetParameters(dctx); } return 0; diff --git a/lib/zstd.h b/lib/zstd.h index 6940a14171f..0092e2b9259 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1170,7 +1170,11 @@ ZSTDLIB_API size_t ZSTD_DCtx_loadDictionary(ZSTD_DCtx* dctx, const void* dict, s * will be determined at decompression time, as per the dict ID in the frame. * The memory for the table is allocated on the first call to refDDict. * The table is freed by ZSTD_DCtx_refDDict(dctx, NULL), by ZSTD_DCtx_reset() - * with ZSTD_reset_parameters or ZSTD_reset_session_and_parameters, and by ZSTD_freeDCtx(). + * with ZSTD_reset_parameters or ZSTD_reset_session_and_parameters, by loading + * a NULL or zero-size dictionary, by disabling multiple-DDict mode, and by + * ZSTD_freeDCtx(). + * Disabling multiple-DDict mode releases all dictionary references from the set; + * no dictionary from the set is retained for subsequent decompression. * * If called with ZSTD_d_refMultipleDDicts disabled (the default), only one dictionary * will be managed, and referencing a dictionary effectively "discards" any previous one. diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 51b578ad51a..e66affd9dd5 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1647,6 +1647,41 @@ static void FUZ_mdict_testZeroID(void) CHECK_Z(ZSTD_freeDDict(raw)); CHECK_Z(ZSTD_freeDDict(full)); } +static void FUZ_mdict_testClearReferences(void) +{ + int mode; + /* Register A, clear its references, free A, then register B and request A. + * The result must be dictionary_wrong, without consulting freed A. */ + for (mode = 0; mode < 5; ++mode) { + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, NULL); + BYTE frame[32], out[32], dummy = 0; + size_t const n = FUZ_mdict_makeFrame(frame, 1111, 0); + size_t r; + switch (mode) { + case 0: CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_parameters)); break; + case 1: CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, NULL, 0)); break; + case 2: CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, &dummy, 0)); break; + case 3: CHECK_Z(ZSTD_DCtx_refDDict(dctx, NULL)); break; + default: + /* Even a dictionary already selected for decoding is released. */ + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 0)); + break; + } + CHECK_Z(ZSTD_freeDDict(a)); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, b)); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(b)); + } +} + /* The DDicts use the default allocator. Counting the DCtx's first 128-slot * table lets the test observe growth without inspecting private structs. */ static void* FUZ_mdict_alloc(void* opaque, size_t size) @@ -1690,6 +1725,7 @@ static unsigned test_multipleDDicts(unsigned testNb) { static const struct { const char* name; void (*run)(void); } tests[] = { {"raw dictionary ID zero", FUZ_mdict_testZeroID}, + {"clearing dictionary references", FUZ_mdict_testClearReferences}, {"hash-table growth", FUZ_mdict_testGrowth}, }; size_t i; From b7ea60678f3781ca5a3a4b6d6fefde07e2012d26 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:07:36 -0800 Subject: [PATCH 66/94] decompress: make local dictionaries and DDict sets mutually exclusive Loading any dictionary or prefix discards the borrowed DDict set without changing the opt-in parameter. refDDict already clears the local dictionary or prefix before registering a borrowed DDict. Document this invariant and test replacement in both directions, including immediate release of borrowed storage. --- lib/decompress/zstd_decompress.c | 4 +-- lib/zstd.h | 15 ++++++++--- tests/fuzzer.c | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index aaa2d9bc356..ae23b859e22 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -364,6 +364,7 @@ void ZSTD_copyDCtx(ZSTD_DCtx* dstDCtx, const ZSTD_DCtx* srcDCtx) */ static void ZSTD_DCtx_selectFrameDDict(ZSTD_DCtx* dctx) { assert(dctx->refMultipleDDicts && dctx->ddictSet); + assert(dctx->ddictLocal == NULL); DEBUGLOG(4, "Adjusting DDict based on requested dict ID from frame"); if (dctx->ddict) { const ZSTD_DDict* frameDDict = ZSTD_DDictHashSet_getDDict(dctx->ddictSet, dctx->fParams.dictID); @@ -1714,8 +1715,7 @@ size_t ZSTD_DCtx_loadDictionary_advanced(ZSTD_DCtx* dctx, { RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); - if (dict == NULL || dictSize == 0) - ZSTD_clearDDictSet(dctx); + ZSTD_clearDDictSet(dctx); if (dict && dictSize != 0) { RETURN_ERROR_IF(dctx->staticSize, memory_allocation, "static DCtx can't allocate an internal DDict"); diff --git a/lib/zstd.h b/lib/zstd.h index 0092e2b9259..5de97664424 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1151,6 +1151,8 @@ ZSTDLIB_API size_t ZSTD_CCtx_refPrefix(ZSTD_CCtx* cctx, * @result : 0, or an error code (which can be tested with ZSTD_isError()). * Special : Adding a NULL (or 0-size) dictionary invalidates any previous dictionary, * meaning "return to no-dictionary mode". + * Loading any dictionary also discards the multiple-DDict reference table, + * without disabling ZSTD_d_refMultipleDDicts. * Note 1 : Loading a dictionary involves building tables, * which has a non-negligible impact on CPU usage and latency. * It's recommended to "load once, use many times", to amortize the cost @@ -1170,11 +1172,12 @@ ZSTDLIB_API size_t ZSTD_DCtx_loadDictionary(ZSTD_DCtx* dctx, const void* dict, s * will be determined at decompression time, as per the dict ID in the frame. * The memory for the table is allocated on the first call to refDDict. * The table is freed by ZSTD_DCtx_refDDict(dctx, NULL), by ZSTD_DCtx_reset() - * with ZSTD_reset_parameters or ZSTD_reset_session_and_parameters, by loading - * a NULL or zero-size dictionary, by disabling multiple-DDict mode, and by - * ZSTD_freeDCtx(). + * with ZSTD_reset_parameters or ZSTD_reset_session_and_parameters, by any + * ZSTD_DCtx_loadDictionary() or ZSTD_DCtx_refPrefix() call, by disabling + * multiple-DDict mode, and by ZSTD_freeDCtx(). * Disabling multiple-DDict mode releases all dictionary references from the set; * no dictionary from the set is retained for subsequent decompression. + * Referencing a DDict invalidates any previously loaded dictionary or prefix. * * If called with ZSTD_d_refMultipleDDicts disabled (the default), only one dictionary * will be managed, and referencing a dictionary effectively "discards" any previous one. @@ -1192,7 +1195,8 @@ ZSTDLIB_API size_t ZSTD_DCtx_refDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict); * Prefix is **only used once**. Reference is discarded at end of frame. * End of frame is reached when ZSTD_decompressStream() returns 0. * @result : 0, or an error code (which can be tested with ZSTD_isError()). - * Note 1 : Adding any prefix (including NULL) invalidates any previously set prefix or dictionary + * Note 1 : Adding any prefix (including NULL) invalidates any previously set prefix or dictionary, + * including the multiple-DDict reference table. * Note 2 : Prefix buffer is referenced. It **must** outlive decompression. * Prefix buffer must remain unmodified up to the end of frame, * reached when ZSTD_decompressStream() returns 0. @@ -2564,6 +2568,9 @@ ZSTDLIB_STATIC_API size_t ZSTD_DCtx_getParameter(ZSTD_DCtx* dctx, ZSTD_dParamete * using a given ZSTD_DCtx, rather than overwriting the previous DDict reference, will instead * store all references. At decompression time, the appropriate dictID is selected * from the set of DDicts based on the dictID in the frame. + * Loading a dictionary or referencing a prefix discards the referenced set, + * without disabling this mode. Referencing a DDict discards any previously + * loaded dictionary or prefix. The two kinds of dictionary storage cannot coexist. * * Usage is simply calling ZSTD_refDDict() on multiple dict buffers. * diff --git a/tests/fuzzer.c b/tests/fuzzer.c index e66affd9dd5..af0e9231509 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1682,6 +1682,51 @@ static void FUZ_mdict_testClearReferences(void) } } +static void FUZ_mdict_testDictionaryReplacement(void) +{ + int mode, used; + for (mode = 0; mode < 5; ++mode) { + for (used = 0; used < 2; ++used) { + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + ZSTD_DDict* const d = FUZ_mdict_createDict(4444, 'D'); + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + BYTE* const dict = (BYTE*)malloc(44); + BYTE frame[32], out[32]; + size_t n, r; + CHECK(dict != NULL); + FUZ_mdict_makeDict(dict, 3333, 'C'); + switch (mode) { + case 0: CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, dict, 44)); break; + case 1: CHECK_Z(ZSTD_DCtx_loadDictionary_byReference(dctx, dict, 44)); break; + case 2: CHECK_Z(ZSTD_DCtx_loadDictionary_advanced(dctx, dict + 28, 16, + ZSTD_dlm_byCopy, ZSTD_dct_rawContent)); break; + case 3: CHECK_Z(ZSTD_DCtx_refPrefix(dctx, dict + 28, 16)); break; + default: CHECK_Z(ZSTD_DCtx_refPrefix_advanced(dctx, dict, 44, ZSTD_dct_fullDict)); break; + } + /* Every local dictionary/prefix replacement releases all borrowed + * DDicts, even though multiple-DDict mode remains enabled. */ + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); + if (used) { + n = FUZ_mdict_makeFrame(frame, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "CCCCCCCC"); + } + /* Going back to the set must discard both an unused and an already + * consumed prefix, as well as a sticky local dictionary. */ + CHECK_Z(ZSTD_DCtx_refDDict(dctx, d)); + free(dict); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + n = FUZ_mdict_makeFrame(frame, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "DDDDDDDD"); + CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(d)); + } + } +} + /* The DDicts use the default allocator. Counting the DCtx's first 128-slot * table lets the test observe growth without inspecting private structs. */ static void* FUZ_mdict_alloc(void* opaque, size_t size) @@ -1726,6 +1771,7 @@ static unsigned test_multipleDDicts(unsigned testNb) static const struct { const char* name; void (*run)(void); } tests[] = { {"raw dictionary ID zero", FUZ_mdict_testZeroID}, {"clearing dictionary references", FUZ_mdict_testClearReferences}, + {"local dictionary replacement", FUZ_mdict_testDictionaryReplacement}, {"hash-table growth", FUZ_mdict_testGrowth}, }; size_t i; From 9244114488ed0bba6eae817f92541f47b6e9da7d Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:08:19 -0800 Subject: [PATCH 67/94] decompress: require exact multi-DDict matches for nonzero frame IDs Select dictionaries only after a complete ordinary frame header is available, before installing dictionary-backed decoding state. Return dictionary_wrong instead of falling back to the current dictionary when the set has no exact nonzero ID match. Frames with ID 0 use a registered ID-0 DDict if present, or no dictionary otherwise. Document the compatibility change and test missing IDs, dictionary-free concatenated frames, raw-content ID-0 dictionaries, and both streaming paths. --- CHANGELOG | 1 + lib/decompress/zstd_decompress.c | 58 +++++++----- lib/zstd.h | 5 + tests/fuzzer.c | 156 ++++++++++++++++++++++++++++++- 4 files changed, 197 insertions(+), 23 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f2c32cda2ee..6e1fcdeedb7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ v1.6.0 (Dec 2025) fix: on a static DCtx, `ZSTD_DCtx_loadDictionary()` and `ZSTD_DCtx_refPrefix()` now return `ZSTD_error_memory_allocation` for nonempty dictionaries and prefixes instead of allocating and leaking an internal DDict api: legacy format support is now disabled by default +api: experimental `ZSTD_d_refMultipleDDicts` now requires an exact match for nonzero frame dictionary IDs in the registered set; missing nonzero IDs return `ZSTD_error_dictionary_wrong` instead of falling back to the current dictionary. Frames with dictionary ID 0 use a registered ID-0 DDict if present, or no dictionary otherwise. api: disabling experimental `ZSTD_d_refMultipleDDicts` releases every dictionary reference in its set; no selected dictionary is retained for subsequent decompression build: `ZSTD_LEGACY_SUPPORT` defaults to `0` in Makefile and CMake diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index ae23b859e22..55b3569e78a 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -358,24 +358,24 @@ void ZSTD_copyDCtx(ZSTD_DCtx* dstDCtx, const ZSTD_DCtx* srcDCtx) * the requested dict ID from the frame. If there exists a reference to the correct ZSTD_DDict, then * accordingly sets the ddict to be used to decompress the frame. * - * If no DDict is found, then no action is taken, and the ZSTD_DCtx::ddict remains as-is. + * Returns dictionary_wrong if the set does not contain a requested nonzero ID. + * For ID 0, selects the registered DDict, or no dictionary if none exists. * * ZSTD_d_refMultipleDDicts must be enabled for this function to be called. */ -static void ZSTD_DCtx_selectFrameDDict(ZSTD_DCtx* dctx) { +static size_t ZSTD_DCtx_selectFrameDDict(ZSTD_DCtx* dctx, U32 dictID) { + const ZSTD_DDict* frameDDict; assert(dctx->refMultipleDDicts && dctx->ddictSet); assert(dctx->ddictLocal == NULL); DEBUGLOG(4, "Adjusting DDict based on requested dict ID from frame"); - if (dctx->ddict) { - const ZSTD_DDict* frameDDict = ZSTD_DDictHashSet_getDDict(dctx->ddictSet, dctx->fParams.dictID); - if (frameDDict) { - DEBUGLOG(4, "DDict found!"); - ZSTD_clearDict(dctx); - dctx->dictID = dctx->fParams.dictID; - dctx->ddict = frameDDict; - dctx->dictUses = ZSTD_use_indefinitely; - } - } + frameDDict = ZSTD_DDictHashSet_getDDict(dctx->ddictSet, dictID); + RETURN_ERROR_IF(frameDDict == NULL && dictID != 0, dictionary_wrong, "No matching DDict in the set"); + DEBUGLOG(4, "DDict lookup complete"); + ZSTD_clearDict(dctx); + dctx->dictID = dictID; + dctx->ddict = frameDDict; + dctx->dictUses = frameDDict ? ZSTD_use_indefinitely : ZSTD_dont_use; + return 0; } @@ -713,7 +713,7 @@ static size_t ZSTD_decodeFrameHeader(ZSTD_DCtx* dctx, const void* src, size_t he /* Reference DDict requested by frame if dctx references multiple ddicts */ if (dctx->refMultipleDDicts == ZSTD_rmd_refMultipleDDicts && dctx->ddictSet) { - ZSTD_DCtx_selectFrameDDict(dctx); + FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(dctx, dctx->fParams.dictID), ""); } #ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION @@ -1082,6 +1082,7 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, { void* const dststart = dst; int moreThan1Frame = 0; + int const useDDictSet = dctx->refMultipleDDicts && dctx->ddictSet != NULL; DEBUGLOG(5, "ZSTD_decompressMultiFrame"); assert(dict==NULL || ddict==NULL); /* either dict or ddict set, not both */ @@ -1092,6 +1093,7 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, } while (srcSize >= ZSTD_startingInputLength(dctx->format)) { + const ZSTD_DDict* frameDDict = ddict; #if defined(ZSTD_LEGACY_SUPPORT) && (ZSTD_LEGACY_SUPPORT >= 1) if (dctx->format == ZSTD_f_zstd1 && ZSTD_isLegacy(src, srcSize)) { @@ -1138,13 +1140,23 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, continue; /* check next frame */ } } - if (ddict) { - /* we were called from ZSTD_decompress_usingDDict */ - FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDDict(dctx, ddict), ""); + if (useDDictSet) { + ZSTD_FrameHeader zfh; + size_t const hSize = ZSTD_getFrameHeader_advanced(&zfh, src, srcSize, dctx->format); + /* Select only from a complete, valid ordinary frame header. + * In particular, a missing ID-0 entry must initialize no dictionary. */ + if (hSize == 0 && zfh.frameType == ZSTD_frame) { + FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(dctx, zfh.dictID), ""); + frameDDict = dctx->ddict; + } + } + if (frameDDict) { + FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDDict(dctx, frameDDict), ""); } else { /* this will initialize correctly with no dict if dict == NULL, so * use this in all cases but ddict */ - FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDict(dctx, dict, dictSize), ""); + FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDict(dctx, + useDDictSet ? NULL : dict, useDDictSet ? 0 : dictSize), ""); } ZSTD_checkContinuity(dctx, dst, dstCapacity); @@ -2163,11 +2175,6 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB } } #endif { size_t const hSize = ZSTD_getFrameHeader_advanced(&zds->fParams, zds->headerBuffer, zds->lhSize, zds->format); - /* Only a complete ordinary frame header can select a DDict. */ - if (hSize == 0 && zds->fParams.frameType == ZSTD_frame - && zds->refMultipleDDicts && zds->ddictSet) { - ZSTD_DCtx_selectFrameDDict(zds); - } if (ZSTD_isError(hSize)) { #if defined(ZSTD_LEGACY_SUPPORT) && (ZSTD_LEGACY_SUPPORT>=1) U32 const legacyVersion = ZSTD_isLegacy(istart, iend-istart); @@ -2211,6 +2218,13 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB break; } } + /* Select only after a complete ordinary frame header is available, + * before any dictionary-backed decoding state is installed. */ + if (zds->refMultipleDDicts && zds->ddictSet + && zds->fParams.frameType == ZSTD_frame) { + FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(zds, zds->fParams.dictID), ""); + } + /* check for single-pass mode opportunity */ if (zds->fParams.frameContentSize != ZSTD_CONTENTSIZE_UNKNOWN && zds->fParams.frameType != ZSTD_skippableFrame diff --git a/lib/zstd.h b/lib/zstd.h index 5de97664424..cf0cb1806c4 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -2568,6 +2568,11 @@ ZSTDLIB_STATIC_API size_t ZSTD_DCtx_getParameter(ZSTD_DCtx* dctx, ZSTD_dParamete * using a given ZSTD_DCtx, rather than overwriting the previous DDict reference, will instead * store all references. At decompression time, the appropriate dictID is selected * from the set of DDicts based on the dictID in the frame. + * When a set exists, an ordinary Zstandard frame with a nonzero dictionary ID must + * have an exact match in the set; otherwise decompression returns + * ZSTD_error_dictionary_wrong. A frame with dictionary ID 0 (including an omitted + * ID) uses the registered ID-0 DDict if present, or no dictionary otherwise. + * Skippable frames do not select a dictionary. * Loading a dictionary or referencing a prefix discards the referenced set, * without disabling this mode. Referencing a DDict discards any previously * loaded dictionary or prefix. The two kinds of dictionary storage cannot coexist. diff --git a/tests/fuzzer.c b/tests/fuzzer.c index af0e9231509..892cd233d3c 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1572,6 +1572,13 @@ static size_t FUZ_mdict_makeFrame(BYTE* dst, U32 id, unsigned flags) return n; } +static size_t FUZ_mdict_makeSkip(BYTE* dst, unsigned variant) +{ + MEM_writeLE32(dst, ZSTD_MAGIC_SKIPPABLE_START + variant); + MEM_writeLE32(dst + 4, 0); /* empty skippable frame */ + return 8; +} + /* The second DDict, when supplied, is initially active. Both remain registered. */ static ZSTD_DCtx* FUZ_mdict_createContext(const ZSTD_DDict* first, const ZSTD_DDict* second) @@ -1719,7 +1726,7 @@ static void FUZ_mdict_testDictionaryReplacement(void) n = FUZ_mdict_makeFrame(frame, 1111, 0); r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); - n = FUZ_mdict_makeFrame(frame, 0, 0); + n = FUZ_mdict_makeFrame(frame, 4444, 0); r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); FUZ_mdict_checkOutput(r, out, "DDDDDDDD"); CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(d)); @@ -1727,6 +1734,150 @@ static void FUZ_mdict_testDictionaryReplacement(void) } } +static void FUZ_mdict_testStreaming(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(7, 'B'); + BYTE data[64], out[32]; + static const struct { unsigned flags; size_t chunk; } cases[] = { + {0, 1}, /* fragmented/buffered */ + {FUZ_mdict_knownSize, sizeof(data)}, /* single-pass shortcut */ + {FUZ_mdict_knownSize | FUZ_mdict_checksum, sizeof(data)} + }; + size_t i, n, r; + + /* Skippable frames do not require a dictionary, including an ID-0 entry. + * The following ordinary frame must still select its exact match. */ + for (i = 0; i < COUNTOF(cases); ++i) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(b, a); + n = FUZ_mdict_makeSkip(data, 7); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), data, n, cases[i].chunk); + CHECK_EQ(r, 0); + n = FUZ_mdict_makeFrame(data, 1111, cases[i].flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), data, n, cases[i].chunk); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + + /* Ordinary frames select an exact match on both buffered and shortcut paths. */ + for (i = 0; i < COUNTOF(cases); ++i) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + n = FUZ_mdict_makeFrame(data, 1111, cases[i].flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), data, n, cases[i].chunk); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + + /* Incomplete and rejected headers must not trigger an ID-0 lookup. + * This set deliberately has no ID-0 entry. */ + for (i = 0; i < 2; ++i) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + ZSTD_inBuffer in = {data, 5, 0}; + ZSTD_outBuffer ob = {out, sizeof(out), 0}; + FUZ_mdict_makeFrame(data, 1111, 0); + if (i) memset(data, 0xff, 5); + r = ZSTD_decompressStream(dctx, &ob, &in); + if (i) { CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_prefix_unknown); } + else { CHECK_Z(r); CHECK(r != 0); CHECK_EQ(in.pos, in.size); } + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + n = FUZ_mdict_makeFrame(data, 1111, 0); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), data, n, 1); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); +} + +static void FUZ_mdict_testStrictMatching(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + ZSTD_DDict* const raw = FUZ_mdict_createDict(0, 'R'); + static const U32 missingIDs[] = {0, 3333}; + BYTE frame[32], out[32]; + size_t i, n, r; + int shortcut; + + for (shortcut = 0; shortcut < 2; ++shortcut) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + unsigned const flags = shortcut ? FUZ_mdict_knownSize : 0; + size_t const chunk = shortcut ? sizeof(frame) : 1; + /* Neither a missing nonzero ID nor an omitted ID may fall back to B. */ + for (i = 0; i < COUNTOF(missingIDs); ++i) { + ZSTD_ErrorCode const expected = missingIDs[i] == 0 + ? ZSTD_error_corruption_detected : ZSTD_error_dictionary_wrong; + n = FUZ_mdict_makeFrame(frame, 2222, flags); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); + n = FUZ_mdict_makeFrame(frame, missingIDs[i], flags); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), expected); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + n = FUZ_mdict_makeFrame(frame, 2222, flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); + n = FUZ_mdict_makeFrame(frame, missingIDs[i], flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + CHECK_EQ(ZSTD_getErrorCode(r), expected); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + } + /* ID 0 is a real key: registering raw R makes an ID-less frame use R, + * even when a different DDict was most recently referenced or used. */ + CHECK_Z(ZSTD_DCtx_refDDict(dctx, raw)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); + n = FUZ_mdict_makeFrame(frame, 0, flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); + n = FUZ_mdict_makeFrame(frame, 1111, flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + n = FUZ_mdict_makeFrame(frame, 0, flags); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); + CHECK_Z(ZSTD_freeDDict(raw)); +} + +static void FUZ_mdict_testNoDictionary(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + BYTE plain[128], frame[32], data[256], out[128]; + size_t const plainSize = ZSTD_compress(plain, sizeof(plain), "no dictionary", 13, 1); + int mode; + CHECK_Z(plainSize); + CHECK_EQ(ZSTD_getDictID_fromFrame(plain, plainSize), 0); + CHECK_EQ(ZSTD_getFrameContentSize(plain, plainSize), 13); + + for (mode = 0; mode < 4; ++mode) { + int const magicless = mode >> 1; + int const shortcut = mode & 1; + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + unsigned const flags = shortcut ? FUZ_mdict_knownSize : 0; + size_t const chunk = shortcut ? sizeof(data) : 1; + size_t const skip = magicless ? 4 : 0; + static const U32 ids[] = {2222, 1111}; + size_t i, n = 0, r; + if (magicless) { CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_format, ZSTD_f_zstd1_magicless)); } + + /* Dictionary-free frames neither inherit the preceding dictionary nor + * prevent the following frame from selecting its own exact match. */ + for (i = 0; i < COUNTOF(ids); ++i) { + size_t const frameSize = FUZ_mdict_makeFrame(frame, ids[i], flags); + memcpy(data + n, frame + skip, frameSize - skip); n += frameSize - skip; + memcpy(data + n, plain + skip, plainSize - skip); n += plainSize - skip; + } + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), data, n); + FUZ_mdict_checkOutput(r, out, "BBBBBBBBno dictionaryAAAAAAAAno dictionary"); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), data, n, chunk); + FUZ_mdict_checkOutput(r, out, "BBBBBBBBno dictionaryAAAAAAAAno dictionary"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); +} + /* The DDicts use the default allocator. Counting the DCtx's first 128-slot * table lets the test observe growth without inspecting private structs. */ static void* FUZ_mdict_alloc(void* opaque, size_t size) @@ -1772,6 +1923,9 @@ static unsigned test_multipleDDicts(unsigned testNb) {"raw dictionary ID zero", FUZ_mdict_testZeroID}, {"clearing dictionary references", FUZ_mdict_testClearReferences}, {"local dictionary replacement", FUZ_mdict_testDictionaryReplacement}, + {"streaming frame selection", FUZ_mdict_testStreaming}, + {"strict dictionary-ID matching", FUZ_mdict_testStrictMatching}, + {"dictionary-free frames", FUZ_mdict_testNoDictionary}, {"hash-table growth", FUZ_mdict_testGrowth}, }; size_t i; From 9eabb4c8c1a4660da9b9edc7e07e1ec5d677aee8 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Wed, 11 Nov 2026 09:37:47 -0800 Subject: [PATCH 68/94] decompress: resolve DDict-set entries per frame without a default Keep the single-DDict state empty while a DDict set exists. Resolve each ordinary frame into a local DDict pointer before installing decoder state, using no dictionary when ID 0 has no registered entry, and leave explicit-dictionary APIs independent of the set. Preserve trace metadata without retaining a borrowed DDict. Reject implicit set-based legacy decoding, retain explicit legacy decoding, and cover immediate release after disabling a set, raw ID 0, dictionary-free frames, and unchanged local-prefix behavior. --- CHANGELOG | 1 + lib/decompress/zstd_ddict.c | 5 +- lib/decompress/zstd_decompress.c | 119 +++++--- lib/decompress/zstd_decompress_internal.h | 3 +- lib/zstd.h | 17 +- tests/fuzzer.c | 356 +++++++++++++++++++++- tests/legacy.c | 52 ++++ 7 files changed, 487 insertions(+), 66 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6e1fcdeedb7..c45657bbb59 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ v1.6.0 (Dec 2025) fix: on a static DCtx, `ZSTD_DCtx_loadDictionary()` and `ZSTD_DCtx_refPrefix()` now return `ZSTD_error_memory_allocation` for nonempty dictionaries and prefixes instead of allocating and leaking an internal DDict api: legacy format support is now disabled by default api: experimental `ZSTD_d_refMultipleDDicts` now requires an exact match for nonzero frame dictionary IDs in the registered set; missing nonzero IDs return `ZSTD_error_dictionary_wrong` instead of falling back to the current dictionary. Frames with dictionary ID 0 use a registered ID-0 DDict if present, or no dictionary otherwise. +api: set-based multi-DDict decoding no longer remembers a selected dictionary; legacy frames require single-dictionary or explicit-dictionary decoding api: disabling experimental `ZSTD_d_refMultipleDDicts` releases every dictionary reference in its set; no selected dictionary is retained for subsequent decompression build: `ZSTD_LEGACY_SUPPORT` defaults to `0` in Makefile and CMake diff --git a/lib/decompress/zstd_ddict.c b/lib/decompress/zstd_ddict.c index 0e550ce03ea..c7a49fe1716 100644 --- a/lib/decompress/zstd_ddict.c +++ b/lib/decompress/zstd_ddict.c @@ -63,7 +63,10 @@ void ZSTD_copyDDictParameters(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) dctx->dictID = ddict->dictID; dctx->prefixStart = ddict->dictContent; dctx->virtualStart = ddict->dictContent; - dctx->dictEnd = (const BYTE*)ddict->dictContent + ddict->dictSize; + /* An empty DDict may have NULL content. */ + dctx->dictEnd = ddict->dictSize != 0 + ? (const BYTE*)ddict->dictContent + ddict->dictSize + : ddict->dictContent; dctx->previousDstEnd = dctx->dictEnd; #ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION dctx->dictContentBeginForFuzzing = dctx->prefixStart; diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c index 55b3569e78a..6322d6cbb80 100644 --- a/lib/decompress/zstd_decompress.c +++ b/lib/decompress/zstd_decompress.c @@ -153,7 +153,7 @@ static size_t ZSTD_DDictHashSet_expand(ZSTD_DDictHashSet* hashSet, ZSTD_customMe /* Fetches a DDict with the given dictID * Returns the ZSTD_DDict* with the requested dictID. If it doesn't exist, then returns NULL. */ -static const ZSTD_DDict* ZSTD_DDictHashSet_getDDict(ZSTD_DDictHashSet* hashSet, U32 dictID) { +static const ZSTD_DDict* ZSTD_DDictHashSet_getDDict(const ZSTD_DDictHashSet* hashSet, U32 dictID) { size_t idx = ZSTD_DDictHashSet_getIndex(hashSet, dictID); const size_t idxRangeMask = hashSet->ddictPtrTableSize - 1; DEBUGLOG(4, "Hashed index: for dictID: %u is %zu", dictID, idx); @@ -354,27 +354,27 @@ void ZSTD_copyDCtx(ZSTD_DCtx* dstDCtx, const ZSTD_DCtx* srcDCtx) ZSTD_memcpy(dstDCtx, srcDCtx, toCopy); /* no need to copy workspace */ } -/* Given a dctx with a digested frame params, re-selects the correct ZSTD_DDict based on - * the requested dict ID from the frame. If there exists a reference to the correct ZSTD_DDict, then - * accordingly sets the ddict to be used to decompress the frame. +/* Look up the DDict for a complete, valid Zstandard frame header. + * The result is for this frame only; it does not change the single-DDict state. * * Returns dictionary_wrong if the set does not contain a requested nonzero ID. - * For ID 0, selects the registered DDict, or no dictionary if none exists. + * For ID 0, returns the registered DDict, or NULL to use no dictionary. * + * Must be called before installing the dictionary's decoding state. * ZSTD_d_refMultipleDDicts must be enabled for this function to be called. */ -static size_t ZSTD_DCtx_selectFrameDDict(ZSTD_DCtx* dctx, U32 dictID) { +static size_t ZSTD_DCtx_getFrameDDict(const ZSTD_DCtx* dctx, U32 dictID, + const ZSTD_DDict** ddict) { const ZSTD_DDict* frameDDict; assert(dctx->refMultipleDDicts && dctx->ddictSet); - assert(dctx->ddictLocal == NULL); - DEBUGLOG(4, "Adjusting DDict based on requested dict ID from frame"); + assert(dctx->ddictLocal == NULL && dctx->ddict == NULL); + assert(dctx->dictUses == ZSTD_dont_use); + DEBUGLOG(4, "Looking up requested dict ID from frame"); frameDDict = ZSTD_DDictHashSet_getDDict(dctx->ddictSet, dictID); RETURN_ERROR_IF(frameDDict == NULL && dictID != 0, dictionary_wrong, "No matching DDict in the set"); DEBUGLOG(4, "DDict lookup complete"); - ZSTD_clearDict(dctx); - dctx->dictID = dictID; - dctx->ddict = frameDDict; - dctx->dictUses = frameDDict ? ZSTD_use_indefinitely : ZSTD_dont_use; + assert(frameDDict == NULL || ZSTD_getDictID_fromDDict(frameDDict) == dictID); + *ddict = frameDDict; return 0; } @@ -703,7 +703,7 @@ unsigned long long ZSTD_getDecompressedSize(const void* src, size_t srcSize) /** ZSTD_decodeFrameHeader() : * `headerSize` must be the size provided by ZSTD_frameHeaderSize(). - * If multiple DDict references are enabled, also will choose the correct DDict to use. + * The dictionary's decoding state must already be installed. * @return : 0 if success, or an error code, which can be tested using ZSTD_isError() */ static size_t ZSTD_decodeFrameHeader(ZSTD_DCtx* dctx, const void* src, size_t headerSize) { @@ -711,11 +711,6 @@ static size_t ZSTD_decodeFrameHeader(ZSTD_DCtx* dctx, const void* src, size_t he if (ZSTD_isError(result)) return result; /* invalid header */ RETURN_ERROR_IF(result>0, srcSize_wrong, "headerSize too small"); - /* Reference DDict requested by frame if dctx references multiple ddicts */ - if (dctx->refMultipleDDicts == ZSTD_rmd_refMultipleDDicts && dctx->ddictSet) { - FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(dctx, dctx->fParams.dictID), ""); - } - #ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION /* Skip the dictID check in fuzzing mode, because it makes the search * harder. @@ -933,9 +928,9 @@ static void ZSTD_DCtx_trace_end(ZSTD_DCtx const* dctx, U64 uncompressedSize, U64 ZSTD_memset(&trace, 0, sizeof(trace)); trace.version = ZSTD_VERSION_NUMBER; trace.streaming = streaming; - if (dctx->ddict) { - trace.dictionaryID = ZSTD_getDictID_fromDDict(dctx->ddict); - trace.dictionarySize = ZSTD_DDict_dictSize(dctx->ddict); + if (dctx->traceDictSize != 0) { + trace.dictionaryID = dctx->dictID; + trace.dictionarySize = dctx->traceDictSize; trace.dictionaryIsCold = dctx->ddictIsCold; } trace.uncompressedSize = (size_t)uncompressedSize; @@ -1078,19 +1073,18 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize, const void* dict, size_t dictSize, - const ZSTD_DDict* ddict) + const ZSTD_DDict* ddict, + int useDDictSet) { void* const dststart = dst; int moreThan1Frame = 0; - int const useDDictSet = dctx->refMultipleDDicts && dctx->ddictSet != NULL; DEBUGLOG(5, "ZSTD_decompressMultiFrame"); assert(dict==NULL || ddict==NULL); /* either dict or ddict set, not both */ - if (ddict) { - dict = ZSTD_DDict_dictContent(ddict); - dictSize = ZSTD_DDict_dictSize(ddict); - } + assert(!useDDictSet || (dctx->refMultipleDDicts && dctx->ddictSet + && dctx->ddictLocal == NULL && dctx->ddict == NULL + && dict == NULL && ddict == NULL)); while (srcSize >= ZSTD_startingInputLength(dctx->format)) { const ZSTD_DDict* frameDDict = ddict; @@ -1098,12 +1092,17 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, #if defined(ZSTD_LEGACY_SUPPORT) && (ZSTD_LEGACY_SUPPORT >= 1) if (dctx->format == ZSTD_f_zstd1 && ZSTD_isLegacy(src, srcSize)) { size_t decodedSize; - size_t const frameSize = ZSTD_findFrameCompressedSizeLegacy(src, srcSize); + const void* const legacyDict = ddict ? ZSTD_DDict_dictContent(ddict) : dict; + size_t const legacyDictSize = ddict ? ZSTD_DDict_dictSize(ddict) : dictSize; + size_t frameSize; + RETURN_ERROR_IF(useDDictSet, parameter_unsupported, + "DDict-set selection is not supported for legacy frames"); + frameSize = ZSTD_findFrameCompressedSizeLegacy(src, srcSize); if (ZSTD_isError(frameSize)) return frameSize; RETURN_ERROR_IF(dctx->staticSize, memory_allocation, "legacy support is not compatible with static dctx"); - decodedSize = ZSTD_decompressLegacy(dst, dstCapacity, src, frameSize, dict, dictSize); + decodedSize = ZSTD_decompressLegacy(dst, dstCapacity, src, frameSize, legacyDict, legacyDictSize); if (ZSTD_isError(decodedSize)) return decodedSize; { @@ -1143,20 +1142,18 @@ size_t ZSTD_decompressMultiFrame(ZSTD_DCtx* dctx, if (useDDictSet) { ZSTD_FrameHeader zfh; size_t const hSize = ZSTD_getFrameHeader_advanced(&zfh, src, srcSize, dctx->format); - /* Select only from a complete, valid ordinary frame header. - * In particular, a missing ID-0 entry must initialize no dictionary. */ - if (hSize == 0 && zfh.frameType == ZSTD_frame) { - FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(dctx, zfh.dictID), ""); - frameDDict = dctx->ddict; - } + /* Invalid/incomplete headers must not change dictionary selection. + * ZSTD_decompressFrame() will report the header error. */ + if (hSize == 0 && zfh.frameType == ZSTD_frame) + FORWARD_IF_ERROR(ZSTD_DCtx_getFrameDDict(dctx, zfh.dictID, &frameDDict), ""); } if (frameDDict) { + /* Use either the explicit DDict or the one selected from the set. */ FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDDict(dctx, frameDDict), ""); } else { /* this will initialize correctly with no dict if dict == NULL, so * use this in all cases but ddict */ - FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDict(dctx, - useDDictSet ? NULL : dict, useDDictSet ? 0 : dictSize), ""); + FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDict(dctx, dict, dictSize), ""); } ZSTD_checkContinuity(dctx, dst, dstCapacity); @@ -1192,12 +1189,13 @@ size_t ZSTD_decompress_usingDict(ZSTD_DCtx* dctx, const void* src, size_t srcSize, const void* dict, size_t dictSize) { - return ZSTD_decompressMultiFrame(dctx, dst, dstCapacity, src, srcSize, dict, dictSize, NULL); + return ZSTD_decompressMultiFrame(dctx, dst, dstCapacity, src, srcSize, dict, dictSize, NULL, 0); } static ZSTD_DDict const* ZSTD_getDDict(ZSTD_DCtx* dctx) { + assert(dctx->ddictSet == NULL); switch (dctx->dictUses) { default: assert(0 /* Impossible */); @@ -1215,6 +1213,11 @@ static ZSTD_DDict const* ZSTD_getDDict(ZSTD_DCtx* dctx) size_t ZSTD_decompressDCtx(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize) { + if (dctx->refMultipleDDicts && dctx->ddictSet) + return ZSTD_decompressMultiFrame(dctx, dst, dstCapacity, src, srcSize, + NULL, 0, NULL, 1); + /* Preserve the single-dictionary behavior: resolve once for the entire + * call, including concatenated frames and single-use prefixes. */ return ZSTD_decompress_usingDDict(dctx, dst, dstCapacity, src, srcSize, ZSTD_getDDict(dctx)); } @@ -1586,6 +1589,7 @@ size_t ZSTD_decompressBegin(ZSTD_DCtx* dctx) assert(dctx != NULL); #if ZSTD_TRACE dctx->traceCtx = (ZSTD_trace_decompress_begin != NULL) ? ZSTD_trace_decompress_begin(dctx) : 0; + dctx->traceDictSize = 0; #endif dctx->expected = ZSTD_startingInputLength(dctx->format); /* dctx->format must be properly set */ dctx->stage = ZSTDds_getFrameHeaderSize; @@ -1616,6 +1620,9 @@ size_t ZSTD_decompressBegin_usingDict(ZSTD_DCtx* dctx, const void* dict, size_t RETURN_ERROR_IF( ZSTD_isError(ZSTD_decompress_insertDictionary(dctx, dict, dictSize)), dictionary_corrupted, ""); +#if ZSTD_TRACE + dctx->traceDictSize = dict ? dictSize : 0; +#endif return 0; } @@ -1629,7 +1636,8 @@ size_t ZSTD_decompressBegin_usingDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) if (ddict) { const char* const dictStart = (const char*)ZSTD_DDict_dictContent(ddict); size_t const dictSize = ZSTD_DDict_dictSize(ddict); - const void* const dictEnd = dictStart + dictSize; + /* An empty DDict may have NULL content. */ + const void* const dictEnd = dictSize != 0 ? dictStart + dictSize : dictStart; dctx->ddictIsCold = (dctx->dictEnd != dictEnd); DEBUGLOG(4, "DDict is %s", dctx->ddictIsCold ? "~cold~" : "hot!"); @@ -1637,6 +1645,9 @@ size_t ZSTD_decompressBegin_usingDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) FORWARD_IF_ERROR( ZSTD_decompressBegin(dctx) , ""); if (ddict) { /* NULL ddict is equivalent to no dictionary */ ZSTD_copyDDictParameters(dctx, ddict); +#if ZSTD_TRACE + dctx->traceDictSize = ZSTD_DDict_dictSize(ddict); +#endif } return 0; } @@ -1682,10 +1693,9 @@ size_t ZSTD_decompress_usingDDict(ZSTD_DCtx* dctx, const void* src, size_t srcSize, const ZSTD_DDict* ddict) { - /* pass content and size in case legacy frames are encountered */ return ZSTD_decompressMultiFrame(dctx, dst, dstCapacity, src, srcSize, NULL, 0, - ddict); + ddict, 0); } @@ -1809,8 +1819,6 @@ size_t ZSTD_DCtx_refDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) RETURN_ERROR_IF(dctx->streamStage != zdss_init, stage_wrong, ""); ZSTD_clearDict(dctx); if (ddict) { - dctx->ddict = ddict; - dctx->dictUses = ZSTD_use_indefinitely; if (dctx->refMultipleDDicts == ZSTD_rmd_refMultipleDDicts) { if (dctx->ddictSet == NULL) { dctx->ddictSet = ZSTD_createDDictHashSet(dctx->customMem); @@ -1820,6 +1828,10 @@ size_t ZSTD_DCtx_refDDict(ZSTD_DCtx* dctx, const ZSTD_DDict* ddict) } assert(!dctx->staticSize); /* Impossible: ddictSet cannot have been allocated if static dctx */ FORWARD_IF_ERROR(ZSTD_DDictHashSet_addDDict(dctx->ddictSet, ddict, dctx->customMem), ""); + } else { + assert(dctx->ddictSet == NULL); + dctx->ddict = ddict; + dctx->dictUses = ZSTD_use_indefinitely; } } else { ZSTD_clearDDictSet(dctx); @@ -1960,7 +1972,8 @@ size_t ZSTD_DCtx_setParameter(ZSTD_DCtx* dctx, ZSTD_dParameter dParam, int value } if (value == ZSTD_rmd_refSingleDDict && dctx->ddictSet != NULL) { /* No dictionary from the set is inherited by single-DDict mode. */ - ZSTD_clearDict(dctx); + assert(dctx->ddict == NULL && dctx->ddictLocal == NULL); + assert(dctx->dictUses == ZSTD_dont_use); ZSTD_clearDDictSet(dctx); } dctx->refMultipleDDicts = (ZSTD_refMultipleDDicts_e)value; @@ -2124,6 +2137,8 @@ static size_t ZSTD_decompressContinueStream( size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inBuffer* input) { + const ZSTD_DDict* frameDDict = NULL; + int const useDDictSet = zds->refMultipleDDicts && zds->ddictSet != NULL; const char* const src = (const char*)input->src; const char* const istart = input->pos != 0 ? src + input->pos : src; const char* const iend = input->size != 0 ? src + input->size : src; @@ -2179,9 +2194,11 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB #if defined(ZSTD_LEGACY_SUPPORT) && (ZSTD_LEGACY_SUPPORT>=1) U32 const legacyVersion = ZSTD_isLegacy(istart, iend-istart); if (legacyVersion) { - ZSTD_DDict const* const ddict = ZSTD_getDDict(zds); + ZSTD_DDict const* const ddict = useDDictSet ? NULL : ZSTD_getDDict(zds); const void* const dict = ddict ? ZSTD_DDict_dictContent(ddict) : NULL; size_t const dictSize = ddict ? ZSTD_DDict_dictSize(ddict) : 0; + RETURN_ERROR_IF(useDDictSet, parameter_unsupported, + "DDict-set selection is not supported for legacy frames"); DEBUGLOG(5, "ZSTD_decompressStream: detected legacy version v0.%u", legacyVersion); RETURN_ERROR_IF(zds->staticSize, memory_allocation, "legacy support is incompatible with static dctx"); @@ -2220,9 +2237,9 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB /* Select only after a complete ordinary frame header is available, * before any dictionary-backed decoding state is installed. */ - if (zds->refMultipleDDicts && zds->ddictSet - && zds->fParams.frameType == ZSTD_frame) { - FORWARD_IF_ERROR(ZSTD_DCtx_selectFrameDDict(zds, zds->fParams.dictID), ""); + frameDDict = NULL; + if (useDDictSet && zds->fParams.frameType == ZSTD_frame) { + FORWARD_IF_ERROR(ZSTD_DCtx_getFrameDDict(zds, zds->fParams.dictID, &frameDDict), ""); } /* check for single-pass mode opportunity */ @@ -2232,7 +2249,8 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB size_t const cSize = ZSTD_findFrameCompressedSize_advanced(istart, (size_t)(iend-istart), zds->format); if (cSize <= (size_t)(iend-istart)) { /* shortcut : using single-pass mode */ - size_t const decompressedSize = ZSTD_decompress_usingDDict(zds, op, (size_t)(oend-op), istart, cSize, ZSTD_getDDict(zds)); + size_t const decompressedSize = ZSTD_decompress_usingDDict(zds, op, (size_t)(oend-op), istart, cSize, + useDDictSet ? frameDDict : ZSTD_getDDict(zds)); if (ZSTD_isError(decompressedSize)) return decompressedSize; DEBUGLOG(4, "shortcut to single-pass ZSTD_decompress_usingDDict()"); assert(istart != NULL); @@ -2254,7 +2272,8 @@ size_t ZSTD_decompressStream(ZSTD_DStream* zds, ZSTD_outBuffer* output, ZSTD_inB /* Consume header (see ZSTDds_decodeFrameHeader) */ DEBUGLOG(4, "Consume header"); - FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDDict(zds, ZSTD_getDDict(zds)), ""); + FORWARD_IF_ERROR(ZSTD_decompressBegin_usingDDict(zds, + useDDictSet ? frameDDict : ZSTD_getDDict(zds)), ""); if (zds->format == ZSTD_f_zstd1 && (MEM_readLE32(zds->headerBuffer) & ZSTD_MAGIC_SKIPPABLE_MASK) == ZSTD_MAGIC_SKIPPABLE_START) { /* skippable frame */ diff --git a/lib/decompress/zstd_decompress_internal.h b/lib/decompress/zstd_decompress_internal.h index 58a5bc8cc8f..a7d51529816 100644 --- a/lib/decompress/zstd_decompress_internal.h +++ b/lib/decompress/zstd_decompress_internal.h @@ -163,7 +163,7 @@ struct ZSTD_DCtx_s /* dictionary */ ZSTD_DDict* ddictLocal; - const ZSTD_DDict* ddict; /* set by ZSTD_initDStream_usingDDict(), or ZSTD_DCtx_refDDict() */ + const ZSTD_DDict* ddict; /* single-DDict state; NULL while ddictSet exists */ U32 dictID; int ddictIsCold; /* if == 1 : dictionary is "new" for working context, and presumed "cold" (not in cpu cache) */ ZSTD_dictUses_e dictUses; @@ -210,6 +210,7 @@ struct ZSTD_DCtx_s /* Tracing */ #if ZSTD_TRACE ZSTD_TraceCtx traceCtx; + size_t traceDictSize; /* size of the dictionary installed for this frame */ #endif }; /* typedef'd to ZSTD_DCtx within "zstd.h" */ diff --git a/lib/zstd.h b/lib/zstd.h index cf0cb1806c4..cf6c7ee9e44 100644 --- a/lib/zstd.h +++ b/lib/zstd.h @@ -1030,7 +1030,8 @@ ZSTDLIB_API size_t ZSTD_freeDDict(ZSTD_DDict* ddict); /*! ZSTD_decompress_usingDDict() : * Decompression using a digested Dictionary. - * Recommended when same dictionary is used multiple times. */ + * Recommended when same dictionary is used multiple times. + * Uses the supplied DDict, regardless of dictionaries referenced in dctx. */ ZSTDLIB_API size_t ZSTD_decompress_usingDDict(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize, @@ -1165,7 +1166,8 @@ ZSTDLIB_API size_t ZSTD_DCtx_loadDictionary(ZSTD_DCtx* dctx, const void* dict, s /*! ZSTD_DCtx_refDDict() : Requires v1.4.0+ * Reference a prepared dictionary, to be used to decompress next frames. - * The dictionary remains active for decompression of future frames using same DCtx. + * In single-DDict mode, the dictionary remains active for decompression of + * future frames using the same DCtx. * * If called with ZSTD_d_refMultipleDDicts enabled, repeated calls of this function * will store the DDict references in a table, and the DDict used for decompression @@ -2567,15 +2569,22 @@ ZSTDLIB_STATIC_API size_t ZSTD_DCtx_getParameter(ZSTD_DCtx* dctx, ZSTD_dParamete * to store references to multiple ZSTD_DDict. That is, multiple calls of ZSTD_refDDict() * using a given ZSTD_DCtx, rather than overwriting the previous DDict reference, will instead * store all references. At decompression time, the appropriate dictID is selected - * from the set of DDicts based on the dictID in the frame. + * from the set of DDicts based on the dictID in each frame. This applies to + * ZSTD_decompressDCtx() and ZSTD_decompressStream(); entry points taking an + * explicit dictionary use that dictionary instead. * When a set exists, an ordinary Zstandard frame with a nonzero dictionary ID must * have an exact match in the set; otherwise decompression returns * ZSTD_error_dictionary_wrong. A frame with dictionary ID 0 (including an omitted * ID) uses the registered ID-0 DDict if present, or no dictionary otherwise. - * Skippable frames do not select a dictionary. + * There is no default dictionary in the set, and a lookup does not change the + * dictionary used by another frame. + * Skippable frames do not select a dictionary. Legacy formats are not supported + * by set-based decoding; use a single or explicitly supplied dictionary instead. * Loading a dictionary or referencing a prefix discards the referenced set, * without disabling this mode. Referencing a DDict discards any previously * loaded dictionary or prefix. The two kinds of dictionary storage cannot coexist. + * Disabling this mode discards the entire set. No dictionary from the set is + * retained for subsequent decompression. * * Usage is simply calling ZSTD_refDDict() on multiple dict buffers. * diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 892cd233d3c..74c084e7219 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -1579,7 +1579,7 @@ static size_t FUZ_mdict_makeSkip(BYTE* dst, unsigned variant) return 8; } -/* The second DDict, when supplied, is initially active. Both remain registered. */ +/* Register one or two DDicts in a new multiple-DDict context. */ static ZSTD_DCtx* FUZ_mdict_createContext(const ZSTD_DDict* first, const ZSTD_DDict* second) { @@ -1614,6 +1614,27 @@ static size_t FUZ_mdict_decompressStream(ZSTD_DCtx* dctx, void* dst, size_t capa return out.pos; } +/* The bufferless API requires exactly the input size requested by the decoder. */ +static size_t FUZ_mdict_decompressContinue(ZSTD_DCtx* dctx, BYTE* dst, size_t capacity, + const BYTE* src, size_t size) +{ + size_t inPos = 0, outPos = 0; + for (;;) { + size_t const n = ZSTD_nextSrcSizeToDecompress(dctx); + size_t r; + if (n == 0) break; + CHECK(inPos <= size && n <= size - inPos); + CHECK(outPos <= capacity); + r = ZSTD_decompressContinue(dctx, outPos != 0 ? dst + outPos : dst, capacity - outPos, + src + inPos, n); + if (ZSTD_isError(r)) return r; + inPos += n; + outPos += r; + } + CHECK_EQ(inPos, size); + return outPos; +} + static void FUZ_mdict_checkOutput(size_t r, const void* out, const char* expected) { CHECK_Z(r); @@ -1621,6 +1642,158 @@ static void FUZ_mdict_checkOutput(size_t r, const void* out, const char* expecte CHECK_EQ(memcmp(out, expected, r), 0); } +static void FUZ_mdict_testOneShot(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + ZSTD_DDict* const raw = FUZ_mdict_createDict(0, 'R'); + BYTE frame[96], out[32], tmp[32]; + size_t n, r; + int magicless, invalid; + + /* B is registered last. Every frame must nevertheless install its own + * exact match, including after reset and within concatenated input. */ + for (magicless = 0; magicless <= 1; ++magicless) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(raw, a); + size_t const skip = magicless ? 4 : 0; + static const U32 ids[] = {1111, 2222, 0}; + size_t i; + CHECK_Z(ZSTD_DCtx_refDDict(dctx, b)); + if (magicless) CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_format, ZSTD_f_zstd1_magicless)); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + skip, n - skip); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + n = FUZ_mdict_makeFrame(frame, 2222, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + skip, n - skip); + FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); + + n = magicless ? 0 : FUZ_mdict_makeSkip(frame, 7); + for (i = 0; i < COUNTOF(ids); ++i) { + size_t const fSize = FUZ_mdict_makeFrame(tmp, ids[i], i == 1 ? FUZ_mdict_knownSize : 0); + memcpy(frame + n, tmp + skip, fSize - skip); + n += fSize - skip; + } + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAABBBBBBBBRRRRRRRR"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + + /* Failed headers must not prevent the next frame from finding its match. */ + for (invalid = 0; invalid < 2; ++invalid) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(raw, a); + FUZ_mdict_makeFrame(frame, 1111, 0); + if (invalid) memset(frame, 0xff, 5); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, 5); + CHECK(ZSTD_isError(r)); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); + CHECK_Z(ZSTD_freeDDict(raw)); +} + +static void FUZ_mdict_testExplicitDictionary(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + BYTE frame[32], out[32], dict[44]; + size_t n, r; + int local; + for (local = 0; local < 2; ++local) { + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(b, NULL); + FUZ_mdict_makeDict(dict, 2222, 'B'); + if (local) CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, dict, sizeof(dict))); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + + /* The set must not override the explicit argument, even though A is + * absent from the set and strict lookup would reject this frame. */ + r = ZSTD_decompress_usingDDict(dctx, out, sizeof(out), frame, n, b); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + r = ZSTD_decompress_usingDDict(dctx, out, sizeof(out), frame, n, a); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + r = ZSTD_decompress_usingDict(dctx, out, sizeof(out), frame, n, dict, sizeof(dict)); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + FUZ_mdict_makeDict(dict, 1111, 'A'); + r = ZSTD_decompress_usingDict(dctx, out, sizeof(out), frame, n, dict, sizeof(dict)); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + + /* Header validation must likewise honor the bufferless API's DDict. */ + CHECK_Z(ZSTD_decompressBegin_usingDDict(dctx, b)); + r = FUZ_mdict_decompressContinue(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_decompressBegin_usingDDict(dctx, a)); + r = FUZ_mdict_decompressContinue(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + + /* Explicit dictionaries must not replace the configured set or the + * context-owned B. Disabling the set must not retain borrowed B. */ + n = FUZ_mdict_makeFrame(frame, 2222, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); + if (!local) { CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 0)); } + n = FUZ_mdict_makeFrame(frame, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + if (local) { FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); } + else { CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); } + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); +} + +static void FUZ_mdict_testPrefixCompatibility(void) +{ + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + BYTE prefix[16], frame[64], out[32]; + size_t n, first, r; + int multiple; + memset(prefix, 'A', sizeof(prefix)); + for (multiple = 0; multiple < 2; ++multiple) { + ZSTD_DCtx* const dctx = multiple ? FUZ_mdict_createContext(b, NULL) : ZSTD_createDCtx(); + CHECK(dctx != NULL); + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, prefix, sizeof(prefix))); + + /* Preserve the existing single-DDict one-shot behavior: a prefix is + * resolved once per call, even across concatenated frames. This also + * applies when the multi-DDict flag is enabled but the set is empty. */ + first = FUZ_mdict_makeFrame(frame, 0, 0); + n = first + FUZ_mdict_makeFrame(frame + first, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAAAAAAAAAA"); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, first); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); + + /* Streaming still consumes the prefix on its first frame. */ + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, prefix, sizeof(prefix))); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, first, 1); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, first, 1); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + + /* Do not change the established skippable-frame prefix behavior in + * this multi-DDict fix. One-shot keeps its dictionary for the call; + * streaming consumes the prefix even on a skippable frame. */ + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, prefix, sizeof(prefix))); + first = FUZ_mdict_makeSkip(frame, 7); + n = first + FUZ_mdict_makeFrame(frame + first, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, prefix, sizeof(prefix))); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, first); + CHECK_EQ(r, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + first, n - first); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); + CHECK_Z(ZSTD_DCtx_refPrefix(dctx, prefix, sizeof(prefix))); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); + CHECK_Z(ZSTD_freeDCtx(dctx)); + } + CHECK_Z(ZSTD_freeDDict(b)); +} + static void FUZ_mdict_testZeroID(void) { U32 const zero = 0; @@ -1637,6 +1810,9 @@ static void FUZ_mdict_testZeroID(void) n = FUZ_mdict_makeFrame(frame, id, 0); /* An occupied ID-0 slot is not a match for the missing nonzero ID. */ + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); @@ -1645,9 +1821,13 @@ static void FUZ_mdict_testZeroID(void) * An actual ID-less frame must still find the raw dictionary itself. */ CHECK_Z(ZSTD_DCtx_refDDict(dctx, full)); CHECK_Z(ZSTD_DCtx_refDDict(dctx, raw)); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); n = FUZ_mdict_makeFrame(frame, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, 1); FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); CHECK_Z(ZSTD_freeDCtx(dctx)); @@ -1689,6 +1869,67 @@ static void FUZ_mdict_testClearReferences(void) } } +static void FUZ_mdict_testDisableSet(void) +{ + int mode; + for (mode = 0; mode < 4; ++mode) { + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DDict* const b = FUZ_mdict_createDict(2222, 'B'); + ZSTD_DDict* const raw = FUZ_mdict_createDict(0, 'R'); + ZSTD_DDict* const d = FUZ_mdict_createDict(3333, 'D'); + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); + BYTE frame[32], out[32]; + size_t n, r; + CHECK_Z(ZSTD_DCtx_refDDict(dctx, raw)); + n = FUZ_mdict_makeFrame(frame, 1111, mode == 3 ? FUZ_mdict_knownSize : 0); + if (mode != 0) { + r = mode == 1 ? ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n) + : FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, + mode == 2 ? 1 : sizeof(frame)); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + } + /* All registered DDicts can be freed immediately, including the most + * recently registered one and any DDict already used for decoding. */ + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 0)); + CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); + CHECK_Z(ZSTD_freeDDict(raw)); + n = FUZ_mdict_makeFrame(frame, 0, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_corruption_detected); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1)); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, d)); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + CHECK_EQ(ZSTD_getErrorCode(r), ZSTD_error_dictionary_wrong); + n = FUZ_mdict_makeFrame(frame, 3333, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "DDDDDDDD"); + CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(d)); + } + + /* With no set, toggling the opt-in parameter does not discard a separately + * configured local dictionary, prefix, or single borrowed DDict. */ + for (mode = 0; mode < 3; ++mode) { + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + BYTE dict[44], frame[32], out[32]; + size_t n, r; + CHECK(dctx != NULL); + FUZ_mdict_makeDict(dict, 1111, 'A'); + if (mode == 0) { CHECK_Z(ZSTD_DCtx_loadDictionary(dctx, dict, sizeof(dict))); } + if (mode == 1) { CHECK_Z(ZSTD_DCtx_refPrefix_advanced(dctx, dict, sizeof(dict), ZSTD_dct_fullDict)); } + if (mode == 2) { CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); } + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1)); + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 0)); + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(a)); + } +} + static void FUZ_mdict_testDictionaryReplacement(void) { int mode, used; @@ -1796,28 +2037,32 @@ static void FUZ_mdict_testStrictMatching(void) static const U32 missingIDs[] = {0, 3333}; BYTE frame[32], out[32]; size_t i, n, r; - int shortcut; + int mode; - for (shortcut = 0; shortcut < 2; ++shortcut) { + for (mode = 0; mode < 4; ++mode) { + int const magicless = mode >> 1; + int const shortcut = mode & 1; ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, b); unsigned const flags = shortcut ? FUZ_mdict_knownSize : 0; size_t const chunk = shortcut ? sizeof(frame) : 1; + size_t const skip = magicless ? 4 : 0; + if (magicless) { CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_format, ZSTD_f_zstd1_magicless)); } /* Neither a missing nonzero ID nor an omitted ID may fall back to B. */ for (i = 0; i < COUNTOF(missingIDs); ++i) { ZSTD_ErrorCode const expected = missingIDs[i] == 0 ? ZSTD_error_corruption_detected : ZSTD_error_dictionary_wrong; n = FUZ_mdict_makeFrame(frame, 2222, flags); - r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + skip, n - skip); FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); n = FUZ_mdict_makeFrame(frame, missingIDs[i], flags); - r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + skip, n - skip); CHECK_EQ(ZSTD_getErrorCode(r), expected); CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); n = FUZ_mdict_makeFrame(frame, 2222, flags); - r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame + skip, n - skip, chunk); FUZ_mdict_checkOutput(r, out, "BBBBBBBB"); n = FUZ_mdict_makeFrame(frame, missingIDs[i], flags); - r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame + skip, n - skip, chunk); CHECK_EQ(ZSTD_getErrorCode(r), expected); CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); } @@ -1826,20 +2071,106 @@ static void FUZ_mdict_testStrictMatching(void) CHECK_Z(ZSTD_DCtx_refDDict(dctx, raw)); CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); n = FUZ_mdict_makeFrame(frame, 0, flags); - r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame + skip, n - skip); + FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame + skip, n - skip, chunk); FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); n = FUZ_mdict_makeFrame(frame, 1111, flags); - r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame + skip, n - skip, chunk); FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); n = FUZ_mdict_makeFrame(frame, 0, flags); - r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame, n, chunk); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), frame + skip, n - skip, chunk); FUZ_mdict_checkOutput(r, out, "RRRRRRRR"); CHECK_Z(ZSTD_freeDCtx(dctx)); } + + /* Ordinary frames compressed without a dictionary work with or without + * an explicitly registered empty ID-0 DDict. */ + { ZSTD_DDict* const empty = ZSTD_createDDict(NULL, 0); + ZSTD_DCtx* const dctx = FUZ_mdict_createContext(a, NULL); + BYTE plain[128]; + CHECK(empty != NULL && ZSTD_getDictID_fromDDict(empty) == 0); + n = ZSTD_compress(plain, sizeof(plain), "no dictionary", 13, 1); + CHECK_Z(n); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), plain, n); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), plain, n, 1); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, empty)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), plain, n); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, a)); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), plain, n, 1); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_freeDCtx(dctx)); CHECK_Z(ZSTD_freeDDict(empty)); + } CHECK_Z(ZSTD_freeDDict(a)); CHECK_Z(ZSTD_freeDDict(b)); CHECK_Z(ZSTD_freeDDict(raw)); } +static void FUZ_mdict_testEmptyDDict(void) +{ + ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); + BYTE dummy = 0, plain[128], emptyFrame[32], frame[32], out[32]; + ZSTD_DDict* const empty[] = { + ZSTD_createDDict(NULL, 0), + ZSTD_createDDict_byReference(NULL, 0), + ZSTD_createDDict(&dummy, 0), + ZSTD_createDDict_byReference(&dummy, 0) + }; + size_t const plainSize = ZSTD_compress(plain, sizeof(plain), "no dictionary", 13, 1); + size_t const emptySize = ZSTD_compress(emptyFrame, sizeof(emptyFrame), NULL, 0, 1); + size_t i; + CHECK_Z(plainSize); + CHECK_Z(emptySize); + + for (i = 0; i < COUNTOF(empty); ++i) { + ZSTD_DCtx* dctx; + size_t n, r; + CHECK(empty[i] != NULL && ZSTD_getDictID_fromDDict(empty[i]) == 0); + dctx = FUZ_mdict_createContext(empty[i], a); + + /* Installing an empty DDict must replace the previous frame's history, + * without doing arithmetic on a possibly NULL dictionary pointer. */ + n = FUZ_mdict_makeFrame(frame, 1111, 0); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), frame, n); + FUZ_mdict_checkOutput(r, out, "AAAAAAAA"); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), plain, plainSize); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), plain, plainSize, 1); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + r = FUZ_mdict_decompressStream(dctx, out, sizeof(out), plain, plainSize, plainSize); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + + /* The explicit and bufferless APIs must accept the same DDict. */ + r = ZSTD_decompress_usingDDict(dctx, out, sizeof(out), plain, plainSize, empty[i]); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_decompressBegin_usingDDict(dctx, empty[i])); + r = FUZ_mdict_decompressContinue(dctx, out, sizeof(out), plain, plainSize); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + + /* Empty frames also permit a NULL, zero-capacity output buffer. */ + CHECK_EQ(ZSTD_decompressDCtx(dctx, NULL, 0, emptyFrame, emptySize), 0); + CHECK_EQ(FUZ_mdict_decompressStream(dctx, NULL, 0, emptyFrame, emptySize, 1), 0); + CHECK_EQ(FUZ_mdict_decompressStream(dctx, NULL, 0, emptyFrame, emptySize, emptySize), 0); + CHECK_EQ(ZSTD_decompress_usingDDict(dctx, NULL, 0, emptyFrame, emptySize, empty[i]), 0); + CHECK_Z(ZSTD_decompressBegin_usingDDict(dctx, empty[i])); + CHECK_EQ(FUZ_mdict_decompressContinue(dctx, NULL, 0, emptyFrame, emptySize), 0); + + CHECK_Z(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 0)); + CHECK_Z(ZSTD_DCtx_refDDict(dctx, empty[i])); + r = ZSTD_decompressDCtx(dctx, out, sizeof(out), plain, plainSize); + FUZ_mdict_checkOutput(r, out, "no dictionary"); + CHECK_Z(ZSTD_freeDCtx(dctx)); + CHECK_Z(ZSTD_freeDDict(empty[i])); + } + CHECK_Z(ZSTD_freeDDict(a)); +} + static void FUZ_mdict_testNoDictionary(void) { ZSTD_DDict* const a = FUZ_mdict_createDict(1111, 'A'); @@ -1920,11 +2251,16 @@ static void FUZ_mdict_testGrowth(void) static unsigned test_multipleDDicts(unsigned testNb) { static const struct { const char* name; void (*run)(void); } tests[] = { + {"one-shot frame selection", FUZ_mdict_testOneShot}, + {"explicit dictionary APIs", FUZ_mdict_testExplicitDictionary}, + {"single-DDict prefix compatibility", FUZ_mdict_testPrefixCompatibility}, {"raw dictionary ID zero", FUZ_mdict_testZeroID}, {"clearing dictionary references", FUZ_mdict_testClearReferences}, + {"disabling the dictionary set", FUZ_mdict_testDisableSet}, {"local dictionary replacement", FUZ_mdict_testDictionaryReplacement}, {"streaming frame selection", FUZ_mdict_testStreaming}, {"strict dictionary-ID matching", FUZ_mdict_testStrictMatching}, + {"empty dictionaries", FUZ_mdict_testEmptyDDict}, {"dictionary-free frames", FUZ_mdict_testNoDictionary}, {"hash-table growth", FUZ_mdict_testGrowth}, }; diff --git a/tests/legacy.c b/tests/legacy.c index b6c372c8956..a4a8da33402 100644 --- a/tests/legacy.c +++ b/tests/legacy.c @@ -397,6 +397,55 @@ static int testLegacyDictionaries(void) return 0; } +#if (ZSTD_LEGACY_SUPPORT <= 7) +static int testMultipleDDicts(void) +{ + size_t const blockSize = strlen(BLOCK); + char* const output = malloc(2 * blockSize); + ZSTD_DCtx* const dctx = ZSTD_createDCtx(); + ZSTD_DDict* const empty = ZSTD_createDDict(NULL, 0); + char frames[FRAME_V08_SIZE + FRAME_V07_SIZE]; + int result = 1; + size_t ret; + + do { + ZSTD_inBuffer input = {FRAME_V07, FRAME_V07_SIZE, 0}; + ZSTD_outBuffer out = {output, 2 * blockSize, 0}; + if (output == NULL || dctx == NULL || empty == NULL) break; + if (ZSTD_isError(ZSTD_DCtx_setParameter(dctx, ZSTD_d_refMultipleDDicts, 1))) break; + if (ZSTD_isError(ZSTD_DCtx_refDDict(dctx, empty))) break; + + ret = ZSTD_decompressDCtx(dctx, output, 2 * blockSize, FRAME_V07, FRAME_V07_SIZE); + if (ZSTD_getErrorCode(ret) != ZSTD_error_parameter_unsupported) break; + if (ZSTD_isError(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only))) break; + ret = ZSTD_decompressStream(dctx, &out, &input); + if (ZSTD_getErrorCode(ret) != ZSTD_error_parameter_unsupported) break; + if (ZSTD_isError(ZSTD_DCtx_reset(dctx, ZSTD_reset_session_only))) break; + + /* A preceding modern frame must not supply an implicit dictionary to + * the legacy frame. Explicit dictionaries remain supported. */ + memcpy(frames, FRAME_V08, FRAME_V08_SIZE); + memcpy(frames + FRAME_V08_SIZE, FRAME_V07, FRAME_V07_SIZE); + ret = ZSTD_decompressDCtx(dctx, output, 2 * blockSize, frames, sizeof(frames)); + if (ZSTD_getErrorCode(ret) != ZSTD_error_parameter_unsupported) break; + ret = ZSTD_decompress_usingDDict(dctx, output, 2 * blockSize, + frames, sizeof(frames), empty); + if (ret != 2 * blockSize || memcmp(output, BLOCK, blockSize) != 0 + || memcmp(output + blockSize, BLOCK, blockSize) != 0) break; + ret = ZSTD_decompress_usingDict(dctx, output, 2 * blockSize, + FRAME_V07, FRAME_V07_SIZE, NULL, 0); + if (ret != blockSize || memcmp(output, BLOCK, blockSize) != 0) break; + result = 0; + } while (0); + + free(output); + ZSTD_freeDCtx(dctx); + ZSTD_freeDDict(empty); + DISPLAY("Multi-DDict legacy boundary %s\n", result ? "FAILED" : "OK"); + return result; +} +#endif + int main(void) { size_t const blockSize = strlen(BLOCK); @@ -440,6 +489,9 @@ int main(void) if (testStreamingAPI(streamable, streamableSize, expected, blockSize * nbStreamFrames)) break; if (testLegacyDictionaries()) break; +#if (ZSTD_LEGACY_SUPPORT <= 7) + if (testMultipleDDicts()) break; +#endif result = 0; } while (0); From 3afea9aec3a9aea8f323343ce35fb0a234284886 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 12 Nov 2026 08:41:16 -0800 Subject: [PATCH 69/94] seekable_format: Reset frame state after failed seeks Invalidate the cached frame before seeking, and record the new frame only after its input and decoder state are initialized. Otherwise a failed seek can leave a new object with a NULL input pointer, and retrying within the same frame bypasses initialization. Add a malformed 65-byte archive regression covering in-memory and custom sources, repeated failed reads, and recovery to a valid frame. --- .../seekable_format/tests/seekable_tests.c | 58 +++++++++++++++++++ contrib/seekable_format/zstdseek_decompress.c | 11 +++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index 9165b03babf..914e4eaa346 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -557,6 +557,64 @@ int main(int argc, const char** argv) } printf("Success!\n"); + printf("Test %u - retry and recover after a failed frame seek: ", testNb++); + { /* A valid raw zstd frame containing "abcdefg", followed by a corrupt + * seek table. Frame 1 is claimed to start at compressed offset 4096, + * beyond this 65-byte input. Its decompressed range is [7, 10). */ + const uint8_t compressed_data[] = { + 0x28, 0xb5, 0x2f, 0xfd, 0x20, 0x07, 0x39, 0x00, 0x00, + 'a', 'b', 'c', 'd', 'e', 'f', 'g', + 0x5e, 0x2a, 0x4d, 0x18, 0x29, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x0a, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x0a, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x0a, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x00, 0xb1, 0xea, 0x92, 0x8f, + }; + struct { + unsigned long long offset; + size_t size; + const char* expected; + } const tests[] = { + {8, 1, NULL}, /* first seek fails before input is initialized */ + {8, 1, NULL}, /* retry the same request */ + {9, 1, NULL}, /* seek forward within the same frame */ + {7, 1, NULL}, /* seek to the start of the same frame */ + {0, 1, "a"}, /* recover by reading the valid first frame */ + {8, 1, NULL}, /* failed seek with an initialized decoder */ + {8, 1, NULL}, + {1, 6, "bcdefg"}, /* resume the previously valid frame */ + }; + unsigned useCustomSource; + for (useCustomSource = 0; useCustomSource < 2; ++useCustomSource) { + char outBuffer[7]; + buffWrapperWithTotal_t buff = {compressed_data, sizeof(compressed_data), 0, 0}; + ZSTD_seekable* const stream = ZSTD_seekable_create(); + size_t initStatus; + size_t idx; + int failed; + assert(stream != NULL); + if (useCustomSource) { + ZSTD_seekable_customFile const src = {&buff, &readBuffWithTotal, &seekBuffWithTotal}; + initStatus = ZSTD_seekable_initAdvanced(stream, src); + } else { + initStatus = ZSTD_seekable_initBuff(stream, compressed_data, sizeof(compressed_data)); + } + failed = ZSTD_isError(initStatus) || ZSTD_seekable_getNumFrames(stream) != 4; + for (idx = 0; !failed && idx < sizeof(tests) / sizeof(tests[0]); ++idx) { + size_t const status = ZSTD_seekable_decompress(stream, outBuffer, tests[idx].size, tests[idx].offset); + if (tests[idx].expected == NULL) { + failed = ZSTD_getErrorCode(status) != ZSTD_error_seekableIO; + } else { + failed = status != tests[idx].size || memcmp(outBuffer, tests[idx].expected, tests[idx].size) != 0; + } + } + ZSTD_seekable_free(stream); + if (failed) goto _test_error; + } + } + printf("Success!\n"); + /* TODO: Add more tests */ printf("Finished tests\n"); return 0; diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index 498bd3d188f..0b57d4cc1e6 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -215,6 +215,10 @@ struct ZSTD_seekable_s { ZSTD_seekable_customFile src; U64 decompressedOffset; + /* (U32)-1 means no frame is ready for decompression. Otherwise this is + * the current frame index. Set to -1 before seeking, so a failed seek + * forces the next call to retry setup. Set to targetFrame only after + * the seek succeeds and the decompression state is initialized. */ U32 curFrame; BYTE inBuff[SEEKABLE_BUFF_SIZE]; /* need to do our own input buffering */ @@ -507,8 +511,9 @@ size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsign do { /* check if we can continue from a previous decompress job */ if (targetFrame != zs->curFrame || offset < zs->decompressedOffset) { - zs->decompressedOffset = zs->seekTable.entries[targetFrame].dOffset; - zs->curFrame = targetFrame; + /* A failed seek must force the next call to reset the stream, + * rather than reuse uninitialized or stale input state. */ + zs->curFrame = (U32)-1; assert(zs->seekTable.entries[targetFrame].cOffset < LLONG_MAX); CHECK_IO(zs->src.seek(zs->src.opaque, @@ -520,6 +525,8 @@ size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsign if (zs->buffWrapper.size && srcBytesRead > zs->buffWrapper.size) { return ERROR(seekableIO); } + zs->decompressedOffset = zs->seekTable.entries[targetFrame].dOffset; + zs->curFrame = targetFrame; } while (zs->decompressedOffset < offset + len) { From bcd09955f700b3d2db332173370fc56976a1ee0a Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 12 Nov 2026 09:29:11 -0800 Subject: [PATCH 70/94] seekable_format: Make decompressor reinitialization safe Reinitialization overwrote the previous seek-table allocation. Read failures while loading a new table could also leak it, and failed reinitialization could leave old decoder state paired with the new source. Release the previous table and reset decoder state before initialization, free partially loaded tables on I/O errors, and reject decompression until initialization succeeds. Document the reset contract and add regression tests for reuse, failure, and recovery across all input APIs. --- .../seekable_format/tests/seekable_tests.c | 222 ++++++++++++++++++ contrib/seekable_format/zstd_seekable.h | 7 +- contrib/seekable_format/zstdseek_decompress.c | 29 ++- 3 files changed, 249 insertions(+), 9 deletions(-) diff --git a/contrib/seekable_format/tests/seekable_tests.c b/contrib/seekable_format/tests/seekable_tests.c index 914e4eaa346..6481c65106a 100644 --- a/contrib/seekable_format/tests/seekable_tests.c +++ b/contrib/seekable_format/tests/seekable_tests.c @@ -79,6 +79,220 @@ static int testFrameSizeBounds(const ZSTD_seekable* stream, const ZSTD_seekTable return 0; } +/* Build a valid archive whose seek table can span multiple read buffers. */ +static void* createRepeatedFrameArchive(const char* data, unsigned numFrames, + int checksumFlag, size_t* archiveSize) +{ + unsigned char frame[128]; + size_t const dataSize = strlen(data); + size_t const frameSize = ZSTD_compress(frame, sizeof(frame), data, dataSize, 1); + size_t const entrySize = checksumFlag ? 12 : 8; + size_t capacity; + unsigned char* archive; + ZSTD_frameLog* frameLog; + unsigned i; + unsigned const checksum = (unsigned)XXH64(data, dataSize, 0); + if (ZSTD_isError(frameSize)) return NULL; + capacity = numFrames * (frameSize + entrySize) + 8 + ZSTD_seekTableFooterSize; + archive = malloc(capacity); + frameLog = ZSTD_seekable_createFrameLog(checksumFlag); + if (archive == NULL || frameLog == NULL) goto _error; + for (i = 0; i < numFrames; ++i) { + size_t const status = ZSTD_seekable_logFrame(frameLog, (unsigned)frameSize, + (unsigned)dataSize, checksum); + if (ZSTD_isError(status)) goto _error; + memcpy(archive + i * frameSize, frame, frameSize); + } + { ZSTD_outBuffer out = {archive, capacity, numFrames * frameSize}; + if (ZSTD_seekable_writeSeekTable(frameLog, &out) != 0) goto _error; + *archiveSize = out.pos; + } + ZSTD_seekable_freeFrameLog(frameLog); + return archive; +_error: + if (frameLog != NULL) ZSTD_seekable_freeFrameLog(frameLog); + free(archive); + return NULL; +} + +typedef struct { + buffWrapperWithTotal_t buff; + unsigned reads; + unsigned seeks; + unsigned failRead; + unsigned failSeek; +} faultyBuff_t; + +static int readFaultyBuff(void* opaque, void* buffer, size_t n) +{ + faultyBuff_t* const buff = (faultyBuff_t*)opaque; + if (++buff->reads == buff->failRead) return -1; + return readBuffWithTotal(&buff->buff, buffer, n); +} + +static int seekFaultyBuff(void* opaque, long long offset, int origin) +{ + faultyBuff_t* const buff = (faultyBuff_t*)opaque; + if (++buff->seeks == buff->failSeek) return -1; + return seekBuffWithTotal(&buff->buff, offset, origin); +} + +static int isUninitialized(ZSTD_seekable* stream) +{ + char output = '!'; + size_t const status = ZSTD_seekable_decompress(stream, &output, 1, 1); + size_t const frameStatus = ZSTD_seekable_decompressFrame(stream, &output, 1, 0); + ZSTD_seekTable* const table = ZSTD_seekTable_create_fromSeekable(stream); + int const result = ZSTD_getErrorCode(status) == ZSTD_error_init_missing + && ZSTD_getErrorCode(frameStatus) == ZSTD_error_init_missing + && output == '!' + && table == NULL + && ZSTD_seekable_getNumFrames(stream) == 0 + && ZSTD_seekable_offsetToFrameIndex(stream, 0) == 0 + && ZSTD_seekable_offsetToFrameIndex(stream, ~0ULL) == 0 + && ZSTD_seekable_getFrameCompressedOffset(stream, 0) == ZSTD_SEEKABLE_FRAMEINDEX_TOOLARGE + && ZSTD_seekable_getFrameDecompressedOffset(stream, 0) == ZSTD_SEEKABLE_FRAMEINDEX_TOOLARGE + && ZSTD_getErrorCode(ZSTD_seekable_getFrameCompressedSize(stream, 0)) == ZSTD_error_frameIndex_tooLarge + && ZSTD_getErrorCode(ZSTD_seekable_getFrameDecompressedSize(stream, 0)) == ZSTD_error_frameIndex_tooLarge; + ZSTD_seekTable_free(table); + return result; +} + +static int testRepeatedInit(void) +{ + const char* const data[2] = {"abcdefg", "ZYXWVUT"}; + unsigned const numFrames[2] = {1, 32768}; + void* archives[2] = {NULL, NULL}; + size_t sizes[2]; + FILE* files[2] = {NULL, NULL}; + buffWrapperWithTotal_t buffers[2]; + ZSTD_seekable* const stream = ZSTD_seekable_create(); + unsigned i; + int success = 0; + if (stream == NULL) goto _cleanup; + for (i = 0; i < 2; ++i) { + archives[i] = createRepeatedFrameArchive(data[i], numFrames[i], (int)i, &sizes[i]); + if (archives[i] == NULL) goto _cleanup; + files[i] = tmpfile(); + if (files[i] == NULL || fwrite(archives[i], 1, sizes[i], files[i]) != sizes[i] + || fflush(files[i]) != 0) goto _cleanup; + buffers[i] = (buffWrapperWithTotal_t){archives[i], sizes[i], 0, 0}; + } + /* Reuse one object across all three APIs and both checksum modes. + * LeakSanitizer/Valgrind also checks that only one table is retained. */ + for (i = 0; i < 30; ++i) { + unsigned const idx = i % 2; + char output[7]; + size_t status; + switch ((i / 2) % 3) { + case 0: + status = ZSTD_seekable_initBuff(stream, archives[idx], sizes[idx]); + break; + case 1: + status = ZSTD_seekable_initFile(stream, files[idx]); + break; + default: + { ZSTD_seekable_customFile const src = {&buffers[idx], &readBuffWithTotal, &seekBuffWithTotal}; + status = ZSTD_seekable_initAdvanced(stream, src); + } + break; + } + if (ZSTD_isError(status) || ZSTD_seekable_getNumFrames(stream) != numFrames[idx]) goto _cleanup; + status = ZSTD_seekable_decompressFrame(stream, output, sizeof(output), numFrames[idx] - 1); + if (status != sizeof(output) || memcmp(output, data[idx], sizeof(output)) != 0) goto _cleanup; + /* Leave buffered output behind for the next initialization. */ + status = ZSTD_seekable_decompress(stream, output, 1, 0); + if (status != 1 || output[0] != data[idx][0]) goto _cleanup; + } + success = 1; +_cleanup: + ZSTD_seekable_free(stream); + for (i = 0; i < 2; ++i) { + if (files[i] != NULL) fclose(files[i]); + free(archives[i]); + } + return success; +} + +static int testFailedInit(void) +{ + unsigned const numFrames = 32768; + const unsigned char badData[ZSTD_seekTableFooterSize] = {0}; + size_t originalSize; + size_t emptySize; + void* const original = createRepeatedFrameArchive("abcdefg", 1, 0, &originalSize); + void* const empty = createRepeatedFrameArchive("", 0, 0, &emptySize); + void* archive = NULL; + FILE* const badFile = tmpfile(); + ZSTD_seekable* const stream = ZSTD_seekable_create(); + unsigned checksumFlag; + int success = 0; + if (original == NULL || empty == NULL || badFile == NULL || stream == NULL) goto _cleanup; + if (fwrite(badData, 1, sizeof(badData), badFile) != sizeof(badData) + || fflush(badFile) != 0 || !isUninitialized(stream)) goto _cleanup; + + for (checksumFlag = 0; checksumFlag < 2; ++checksumFlag) { + size_t archiveSize; + unsigned failure; + archive = createRepeatedFrameArchive("ZYXWVUT", numFrames, (int)checksumFlag, &archiveSize); + if (archive == NULL) goto _cleanup; + for (failure = 0; failure < 8; ++failure) { + faultyBuff_t buff = {{archive, archiveSize, 0, 0}, 0, 0, 0, 0}; + ZSTD_seekable_customFile const src = {&buff, &readFaultyBuff, &seekFaultyBuff}; + ZSTD_ErrorCode const expected = failure < 2 ? ZSTD_error_prefix_unknown : ZSTD_error_seekableIO; + char output[7]; + size_t status = ZSTD_seekable_initBuff(stream, original, originalSize); + if (ZSTD_isError(status)) goto _cleanup; + status = ZSTD_seekable_decompress(stream, output, 1, 0); + if (status != 1 || output[0] != 'a') goto _cleanup; + + if (failure == 0) { + status = ZSTD_seekable_initBuff(stream, badData, sizeof(badData)); + } else if (failure == 1) { + status = ZSTD_seekable_initFile(stream, badFile); + } else { + /* Reads 1/2 happen before allocation; reads 3/4 after it. + * The loader also performs two seeks before allocation. */ + if (failure < 6) buff.failRead = failure - 1; + else buff.failSeek = failure - 5; + status = ZSTD_seekable_initAdvanced(stream, src); + } + if (ZSTD_getErrorCode(status) != expected || !isUninitialized(stream)) goto _cleanup; + + /* Consecutive failed initializations must also be harmless. */ + status = ZSTD_seekable_initBuff(stream, badData, sizeof(badData)); + if (ZSTD_getErrorCode(status) != ZSTD_error_prefix_unknown || !isUninitialized(stream)) goto _cleanup; + + /* A later successful initialization must use only the new input. */ + buff.reads = buff.seeks = buff.failRead = buff.failSeek = 0; + status = ZSTD_seekable_initAdvanced(stream, src); + if (ZSTD_isError(status) || ZSTD_seekable_getNumFrames(stream) != numFrames) goto _cleanup; + status = ZSTD_seekable_decompressFrame(stream, output, sizeof(output), numFrames - 1); + if (status != sizeof(output) || memcmp(output, "ZYXWVUT", sizeof(output)) != 0) goto _cleanup; + } + free(archive); + archive = NULL; + } + + /* A successfully initialized empty archive is not an uninitialized object. */ + if (ZSTD_isError(ZSTD_seekable_initBuff(stream, empty, emptySize))) goto _cleanup; + { char output; + if (ZSTD_seekable_getNumFrames(stream) != 0 + || ZSTD_seekable_offsetToFrameIndex(stream, 0) != 0 + || ZSTD_seekable_decompress(stream, &output, 1, 0) != 0 + || ZSTD_getErrorCode(ZSTD_seekable_decompressFrame(stream, &output, 1, 0)) != ZSTD_error_frameIndex_tooLarge) + goto _cleanup; + } + success = 1; +_cleanup: + ZSTD_seekable_free(stream); + if (badFile != NULL) fclose(badFile); + free(archive); + free(empty); + free(original); + return success; +} + /* Basic unit tests for zstd seekable format */ int main(int argc, const char** argv) { @@ -615,6 +829,14 @@ int main(int argc, const char** argv) } printf("Success!\n"); + printf("Test %u - repeated initialization across source types: ", testNb++); + if (!testRepeatedInit()) goto _test_error; + printf("Success!\n"); + + printf("Test %u - failed initialization and recovery: ", testNb++); + if (!testFailedInit()) goto _test_error; + printf("Success!\n"); + /* TODO: Add more tests */ printf("Finished tests\n"); return 0; diff --git a/contrib/seekable_format/zstd_seekable.h b/contrib/seekable_format/zstd_seekable.h index 5449fd9292b..1c07fdb18a7 100644 --- a/contrib/seekable_format/zstd_seekable.h +++ b/contrib/seekable_format/zstd_seekable.h @@ -123,7 +123,12 @@ ZSTDLIB_API size_t ZSTD_seekable_writeSeekTable(ZSTD_frameLog* fl, ZSTD_outBuffe * A ZSTD_seekable object is required to tracking the seekTable. * * Call ZSTD_seekable_init* to initialize a ZSTD_seekable object with the -* the seek table provided in the input. +* seek table provided in the input. These functions may also be called again +* to reset the object for a new input. Resetting discards the previous input +* and seek table, even if initialization fails. Before the first successful +* initialization, or after a failed initialization, the object has no frames +* and decompression returns an initialization-missing error. The object may +* be initialized again or freed. * There are three modes for ZSTD_seekable_init: * - ZSTD_seekable_initBuff() : An in-memory API. The data contained in * `src` should be the entire seekable file, including the seek table. diff --git a/contrib/seekable_format/zstdseek_decompress.c b/contrib/seekable_format/zstdseek_decompress.c index 0b57d4cc1e6..7e10629b883 100644 --- a/contrib/seekable_format/zstdseek_decompress.c +++ b/contrib/seekable_format/zstdseek_decompress.c @@ -303,7 +303,7 @@ unsigned ZSTD_seekTable_offsetToFrameIndex(const ZSTD_seekTable* st, unsigned lo U32 hi = (U32)st->tableLen; assert(st->tableLen <= UINT_MAX); - if (pos >= st->entries[st->tableLen].dOffset) { + if (hi == 0 || pos >= st->entries[st->tableLen].dOffset) { return (unsigned)st->tableLen; } @@ -439,7 +439,10 @@ static size_t ZSTD_seekable_loadSeekTable(ZSTD_seekable* zs) U32 const offset = SEEKABLE_BUFF_SIZE - pos; U32 const toRead = MIN(remaining, SEEKABLE_BUFF_SIZE - offset); memmove(zs->inBuff, zs->inBuff + pos, offset); /* move any data we haven't read yet */ - CHECK_IO(src.read(src.opaque, zs->inBuff+offset, toRead)); + if (src.read(src.opaque, zs->inBuff+offset, toRead) < 0) { + free(entries); + return ERROR(seekableIO); + } remaining -= toRead; pos = 0; } @@ -484,21 +487,30 @@ size_t ZSTD_seekable_initFile(ZSTD_seekable* zs, FILE* src) size_t ZSTD_seekable_initAdvanced(ZSTD_seekable* zs, ZSTD_seekable_customFile src) { - zs->src = src; - - { const size_t seekTableInit = ZSTD_seekable_loadSeekTable(zs); - if (ZSTD_isError(seekTableInit)) return seekTableInit; } - + /* Reinitialization discards the previous archive, even on failure. + * A NULL entries pointer marks a context that is not initialized. */ + free(zs->seekTable.entries); + zs->seekTable = (ZSTD_seekTable){NULL, 0, 0}; zs->decompressedOffset = (U64)-1; zs->curFrame = (U32)-1; + /* Discard buffered input from the previous source, even on failure. */ + zs->in = (ZSTD_inBuffer){zs->inBuff, 0, 0}; + zs->src = src; + /* When not reading from a buffer via initBuff() clear the buffWrapper + * to avoid inheriting its state. */ + if (src.opaque != &zs->buffWrapper) { + zs->buffWrapper = (buffWrapper_t){NULL, 0, 0}; + } { const size_t dstreamInit = ZSTD_initDStream(zs->dstream); if (ZSTD_isError(dstreamInit)) return dstreamInit; } - return 0; + /* Publish the new seek table only after all initialization has succeeded. */ + return ZSTD_seekable_loadSeekTable(zs); } size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsigned long long offset) { + if (zs->seekTable.entries == NULL) return ERROR(init_missing); unsigned long long const eos = zs->seekTable.entries[zs->seekTable.tableLen].dOffset; if (offset > eos) return ERROR(parameter_outOfBound); if (len > eos - offset) { @@ -607,6 +619,7 @@ size_t ZSTD_seekable_decompress(ZSTD_seekable* zs, void* dst, size_t len, unsign size_t ZSTD_seekable_decompressFrame(ZSTD_seekable* zs, void* dst, size_t dstSize, unsigned frameIndex) { + if (zs->seekTable.entries == NULL) return ERROR(init_missing); if (frameIndex >= zs->seekTable.tableLen) { return ERROR(frameIndex_tooLarge); } From ea8db4fa7b5b50c499ea6e879c1b78664f58d485 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 21:20:15 -0800 Subject: [PATCH 71/94] build: link the 32-bit targets without -z cet-report=error libzstd.mk adds -z cet-report=error when the compiler accepts it, but it tests this in 64-bit mode. zstd32, fullbench32, fuzzer32 and zstreamtest32 add -m32 afterwards, and 32-bit system objects usually have no CET properties: with gcc on Ubuntu 24.04 and 26.04, `make -C programs zstd32` fails with "missing IBT and SHSTK properties". The sanitizer targets hid it, because they forced the gold linker, which ignores this option. These targets now link with LDFLAGS_32, which leaves the option out. 64-bit links keep it. --- lib/libzstd.mk | 2 ++ programs/Makefile | 1 + tests/Makefile | 3 +++ 3 files changed, 6 insertions(+) diff --git a/lib/libzstd.mk b/lib/libzstd.mk index 60928b0ee95..835ef99f313 100644 --- a/lib/libzstd.mk +++ b/lib/libzstd.mk @@ -134,6 +134,8 @@ endif ifeq ($(shell echo "int main(int argc, char* argv[]) { (void)argc; (void)argv; return 0; }" | $(CC) $(FLAGS) -z cet-report=error -x c -Werror - -o $(VOID) 2>$(VOID) && echo 1 || echo 0),1) LDFLAGS += -z cet-report=error endif +# 32-bit system objects usually have no CET properties: 32-bit targets link with LDFLAGS_32 +LDFLAGS_32 = $(subst -z cet-report=error,,$(LDFLAGS)) HAVE_COLORNEVER = $(shell echo a | grep --color=never a > /dev/null 2> /dev/null && echo 1 || echo 0) GREP_OPTIONS ?= diff --git a/programs/Makefile b/programs/Makefile index 94f2179d028..11dd93b7572 100644 --- a/programs/Makefile +++ b/programs/Makefile @@ -186,6 +186,7 @@ zstd-release: DEBUGFLAGS_LD := zstd-release: zstd CLEAN += zstd32 +zstd32 : LDFLAGS := $(LDFLAGS_32) zstd32 : CPPFLAGS += $(THREAD_CPP) zstd32 : LDFLAGS += $(THREAD_LD) zstd32 : CPPFLAGS += -DZSTD_LEGACY_SUPPORT=$(ZSTD_LEGACY_SUPPORT) diff --git a/tests/Makefile b/tests/Makefile index e268ddeb9e4..d12e42f3613 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -149,6 +149,7 @@ zstdmt_l_%.o : $(LIB_SRCDIR)/legacy/%.c FULLBENCHS := fullbench fullbench32 CLEAN += $(FULLBENCHS) fullbench32: CPPFLAGS += -m32 +fullbench32: LDFLAGS := $(LDFLAGS_32) $(FULLBENCHS) : CPPFLAGS += $(MULTITHREAD_CPP) -Wno-deprecated-declarations $(FULLBENCHS) : LDFLAGS += $(MULTITHREAD_LD) $(FULLBENCHS) : DEBUGFLAGS = -DNDEBUG # turn off assert() for speed measurements @@ -174,6 +175,7 @@ fuzzer : $(ZSTDMT_OBJECTS) fuzzer fuzzer32 : $(ZDICT_FILES) $(PRGDIR)/util.c $(PRGDIR)/timefn.c $(PRGDIR)/datagen.c fuzzer.c fuzzer32 : CFLAGS += -m32 $(MULTITHREAD) +fuzzer32 : LDFLAGS := $(LDFLAGS_32) fuzzer32 : $(ZSTD_FILES) $(LINK.c) $^ -o $@$(EXT) @@ -186,6 +188,7 @@ ZSTREAM_LOCAL_FILES := $(PRGDIR)/datagen.c $(PRGDIR)/util.c $(PRGDIR)/timefn.c s ZSTREAM_PROPER_FILES := $(ZDICT_FILES) $(ZSTREAM_LOCAL_FILES) ZSTREAMFILES := $(ZSTD_FILES) $(ZSTREAM_PROPER_FILES) zstreamtest32 : CFLAGS += -m32 +zstreamtest32 : LDFLAGS := $(LDFLAGS_32) zstreamtest zstreamtest32 : CPPFLAGS += $(MULTITHREAD_CPP) zstreamtest zstreamtest32 : LDFLAGS += $(MULTITHREAD_LD) zstreamtest : $(ZSTDMT_OBJECTS) $(ZSTREAM_PROPER_FILES) From 76d7a083c97a157a2f70e0b7afb26a9589e39766 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 22:15:12 -0800 Subject: [PATCH 72/94] ci: lower the mmap ASLR entropy in the gcc-8 sanitizer jobs The ASan runtime of gcc-8 loops on AddressSanitizer:DEADLYSIGNAL when the kernel uses 32 bits of mmap randomization. The ubuntu-24.04 image sets vm.mmap_rnd_bits=28; ubuntu-26.04 does not, and gcc-8-asan-ubsan-fuzz printed 102 million such lines there. Both gcc-8 jobs now set it themselves. Newer ASan runtimes (gcc 15, clang) are not affected. --- .github/workflows/dev-long-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index c561dd71ca8..dee34bd2d31 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -115,6 +115,8 @@ jobs: - name: gcc-8 + ASan + UBSan + Test Zstd # See https://askubuntu.com/a/1428822 run: | + # the ASan runtime of gcc-8 loops on DEADLYSIGNAL with the 32-bit mmap ASLR of recent kernels + sudo sysctl -w vm.mmap_rnd_bits=28 echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu focal main universe" | sudo tee -a /etc/apt/sources.list sudo apt-get -qqq update make gcc8install @@ -155,6 +157,8 @@ jobs: - name: gcc-8 + ASan + UBSan + Fuzz Test # See https://askubuntu.com/a/1428822 run: | + # the ASan runtime of gcc-8 loops on DEADLYSIGNAL with the 32-bit mmap ASLR of recent kernels + sudo sysctl -w vm.mmap_rnd_bits=28 echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu focal main universe" | sudo tee -a /etc/apt/sources.list sudo apt-get -qqq update make gcc8install From 3c66614fd4b699767dbac3e6275128ef4cdba1d7 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 17:18:14 -0800 Subject: [PATCH 73/94] build: stop forcing the gold linker in the sanitizer targets LDFLAGS=-fuse-ld=gold was added in 2017 for Travis CI (f58ac79f5). The sanitizers link with the default linker, and gold is deprecated in binutils: it is not installed on ubuntu-26.04 runners, so these targets failed there. The 15 sanitizer jobs, including clang-asan-ubsan-cli-tests, return to ubuntu-latest. --- .github/workflows/dev-long-tests.yml | 30 ++++++++++++++-------------- Makefile | 8 ++++---- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index dee34bd2d31..64bed31f151 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -76,14 +76,14 @@ jobs: # lasts ~8mn tsan-zstreamtest: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: thread sanitizer zstreamtest run: CC=clang ZSTREAM_TESTTIME=-T3mn make tsan-test-zstream uasan-zstreamtest: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: ub + address sanitizer on zstreamtest @@ -91,7 +91,7 @@ jobs: # lasts ~5mn tsan-fuzztest: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: thread sanitizer fuzztest @@ -109,7 +109,7 @@ jobs: # lasts ~8mn gcc-8-asan-ubsan-testzstd: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no gold linker + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: gcc-8 + ASan + UBSan + Test Zstd @@ -123,21 +123,21 @@ jobs: CC=gcc-8 make -j uasan-test-zstd Date: Thu, 12 Nov 2026 17:32:59 -0800 Subject: [PATCH 74/94] build: install qemu-user, and call its binaries when they are installed qemu-user-static is only a virtual package on ubuntu-26.04, so the ARM and PowerPC install targets failed there. arminstall and ppcinstall now install qemu-user, which exists from Ubuntu 20.04 to 26.04 and on Debian 12, and does not remove an installed qemu-user-static. qemu-user names its binaries qemu-, without the -static suffix. The targets call qemu- when qemu-arm is installed, and qemu--static otherwise, as before: a machine with only qemu-user-static keeps working. The tests call every cross-compiled binary through QEMU_SYS, so no binfmt registration is needed. armbuild and armfuzz return to ubuntu-latest. --- .github/workflows/dev-long-tests.yml | 2 +- .github/workflows/dev-short-tests.yml | 2 +- Makefile | 31 +++++++++++++++------------ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index 64bed31f151..f85b7a7bab3 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -270,7 +270,7 @@ jobs: CC=clang make -j msan-test-zstd HAVE_ZLIB=0 HAVE_LZ4=0 HAVE_LZMA=0 ZSTDRTTEST= V=1 armfuzz: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no qemu-user-static package + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: Qemu ARM emulation + Fuzz Test diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index 4c037ffd07f..063e12677ad 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -98,7 +98,7 @@ jobs: CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ CFLAGS="-Werror -O1" make zstd armbuild: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: no qemu-user-static package + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: ARM Build Test diff --git a/Makefile b/Makefile index 2517d7496a1..44ff3ad57ce 100644 --- a/Makefile +++ b/Makefile @@ -238,19 +238,22 @@ ppcbuild: clean ppc64build: clean CC=powerpc-linux-gnu-gcc CFLAGS="-m64 -Werror" $(MAKE) -j allzstd +# qemu-user names its binaries qemu-; qemu-user-static, before Ubuntu 26.04, names them qemu--static +QEMU_SUFFIX = $(if $(shell command -v qemu-arm 2>/dev/null),,-static) + .PHONY: armfuzz aarch64fuzz ppcfuzz ppc64fuzz armfuzz: clean - CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm-static MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest + CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm$(QEMU_SUFFIX) MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest aarch64fuzz: clean ld -v - CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64-static MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest + CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64$(QEMU_SUFFIX) MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest ppcfuzz: clean - CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc-static MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest + CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc$(QEMU_SUFFIX) MOREFLAGS="-static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest ppc64fuzz: clean - CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64-static MOREFLAGS="-m64 -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest + CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64$(QEMU_SUFFIX) MOREFLAGS="-m64 -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" $(MAKE) -C $(TESTDIR) fuzztest .PHONY: cxxtest gcc5test gcc6test armtest aarch64test ppctest ppc64test cxxtest: CXXFLAGS += -Wall -Wextra -Wundef -Wshadow -Wcast-align -Werror @@ -267,26 +270,26 @@ gcc6test: clean armtest: clean $(MAKE) -C $(TESTDIR) datagen # use native, faster - $(MAKE) -C $(TESTDIR) test CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm-static ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" + $(MAKE) -C $(TESTDIR) test CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" aarch64test: $(MAKE) -C $(TESTDIR) datagen # use native, faster - $(MAKE) -C $(TESTDIR) test CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64-static ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" + $(MAKE) -C $(TESTDIR) test CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" ppctest: clean $(MAKE) -C $(TESTDIR) datagen # use native, faster - $(MAKE) -C $(TESTDIR) test CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc-static ZSTDRTTEST= MOREFLAGS="-Werror -Wno-attributes -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" + $(MAKE) -C $(TESTDIR) test CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -Wno-attributes -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" ppc64test: clean $(MAKE) -C $(TESTDIR) datagen # use native, faster - $(MAKE) -C $(TESTDIR) test CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64-static ZSTDRTTEST= MOREFLAGS="-m64 -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" + $(MAKE) -C $(TESTDIR) test CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-m64 -static $(MOREFLAGS)" FUZZER_FLAGS="--no-big-tests $(FUZZER_FLAGS)" .PHONY: arm-ppc-compilation arm-ppc-compilation: - $(MAKE) -C $(PRGDIR) clean zstd CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm-static ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" - $(MAKE) -C $(PRGDIR) clean zstd CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64-static ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" - $(MAKE) -C $(PRGDIR) clean zstd CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc-static ZSTDRTTEST= MOREFLAGS="-Werror -Wno-attributes -static $(MOREFLAGS)" - $(MAKE) -C $(PRGDIR) clean zstd CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64-static ZSTDRTTEST= MOREFLAGS="-m64 -static $(MOREFLAGS)" + $(MAKE) -C $(PRGDIR) clean zstd CC=arm-linux-gnueabi-gcc QEMU_SYS=qemu-arm$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" + $(MAKE) -C $(PRGDIR) clean zstd CC=aarch64-linux-gnu-gcc QEMU_SYS=qemu-aarch64$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -static $(MOREFLAGS)" + $(MAKE) -C $(PRGDIR) clean zstd CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-Werror -Wno-attributes -static $(MOREFLAGS)" + $(MAKE) -C $(PRGDIR) clean zstd CC=powerpc-linux-gnu-gcc QEMU_SYS=qemu-ppc64$(QEMU_SUFFIX) ZSTDRTTEST= MOREFLAGS="-m64 -static $(MOREFLAGS)" regressiontest: $(MAKE) -C $(FUZZDIR) regressiontest @@ -356,10 +359,10 @@ apt-add-repo: .PHONY: ppcinstall arminstall valgrindinstall libc6install gcc6install gcc7install gcc8install gpp6install clang38install ppcinstall: - APT_PACKAGES="qemu-system-ppc qemu-user-static gcc-powerpc-linux-gnu" $(MAKE) apt-install + APT_PACKAGES="qemu-system-ppc qemu-user gcc-powerpc-linux-gnu" $(MAKE) apt-install arminstall: - APT_PACKAGES="qemu-system-arm qemu-user-static gcc-arm-linux-gnueabi libc6-dev-armel-cross gcc-aarch64-linux-gnu libc6-dev-arm64-cross" $(MAKE) apt-install + APT_PACKAGES="qemu-system-arm qemu-user gcc-arm-linux-gnueabi libc6-dev-armel-cross gcc-aarch64-linux-gnu libc6-dev-arm64-cross" $(MAKE) apt-install valgrindinstall: APT_PACKAGES="valgrind" $(MAKE) apt-install From 0ac4e753752ad2f94c46d7d0508f88e9e0b77320 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 17:36:32 -0800 Subject: [PATCH 75/94] ci: install the powerpc multilib that matches the cross compiler short-tests-1 installed lib64gcc-13-dev-powerpc-cross, which only matches the GCC 13 cross compiler of ubuntu-24.04: with GCC 15 on ubuntu-26.04, ppc64build could not find -lgcc. gcc-multilib-powerpc-linux-gnu installs the 64-bit libraries of the default cross compiler, whatever its version. short-tests-1 returns to ubuntu-latest. --- .github/workflows/commit.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index ebad92f9e1a..6b991c50565 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -34,7 +34,7 @@ jobs: make cxxtest; make clean short-tests-1: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: powerpc cross toolchain cannot find -lgcc + runs-on: ubuntu-latest services: docker: image: fbopensource/zstd-circleci-primary:0.0.1 @@ -44,7 +44,7 @@ jobs: - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install gcc-powerpc-linux-gnu gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu libc6-dev-ppc64-powerpc-cross libcurl4-gnutls-dev lib64gcc-13-dev-powerpc-cross + sudo apt-get install gcc-powerpc-linux-gnu gcc-arm-linux-gnueabi gcc-aarch64-linux-gnu libc6-dev-ppc64-powerpc-cross libcurl4-gnutls-dev gcc-multilib-powerpc-linux-gnu - name: gnu90 build run: make gnu90build && make clean - name: gnu99 build From 9131d8d93b0c494f29b1cb08e20f7cd1916866f7 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 17:36:32 -0800 Subject: [PATCH 76/94] tests: build the old versions for x86-64 in the versions test GCC on ubuntu-26.04 runners targets x86-64-v3 by default. With BMI2 enabled, v1.3.1 does not compile: its threading.h defines pthread_mutex_t as a macro, which then clashes with the glibc type pulled in by . The versions test now builds with -march=x86-64 on x86-64 hosts. Verified with GCC 15 on Ubuntu 26.04 and x86-64-v3 as default target: the 46 versions build and the test passes. versions-compatibility returns to ubuntu-latest. --- .github/workflows/dev-short-tests.yml | 2 +- tests/test-zstd-versions.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index 063e12677ad..6610a7a16ae 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -589,7 +589,7 @@ jobs: ./simple LICENSE versions-compatibility: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: old versions do not compile in C23 + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: Versions Compatibility Test diff --git a/tests/test-zstd-versions.py b/tests/test-zstd-versions.py index 57131c24349..9af75a7c0d4 100755 --- a/tests/test-zstd-versions.py +++ b/tests/test-zstd-versions.py @@ -15,6 +15,7 @@ import glob import hashlib import os +import platform import shutil import sys import subprocess @@ -23,7 +24,12 @@ repo_url = 'https://github.com/facebook/zstd.git' tmp_dir_name = 'tests/versionsTest' make_cmd = 'make' -make_args = ['-j','CFLAGS=-O0'] +# Some old versions do not compile when the default target enables BMI2, +# as GCC does on ubuntu-26.04 runners (x86-64-v3). +cflags = '-O0' +if platform.machine() in ('x86_64', 'AMD64'): + cflags += ' -march=x86-64' +make_args = ['-j', 'CFLAGS=' + cflags] git_cmd = 'git' test_dat_src = 'README.md' test_dat = 'test_dat' From 1129870b05cef9daed1763a2bb62e0c396ac3e2d Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 17:38:05 -0800 Subject: [PATCH 77/94] ci: measure the libzstd size at a fixed target, with a uniform margin The size limits had margins between 1% and 100%. On ubuntu-26.04 runners, GCC 15 targets x86-64-v3 by default, which makes libzstd.so 21% larger (1105472 bytes instead of 909760), above the 1100000 limit. The test now builds with -march=x86-64, so the measure does not depend on the default target of the compiler. Each limit is the size measured with GCC 15.2, plus about 30%, rounded up: this still catches a jump such as #3066. check_size.py prints every size, to make the next measure easy. The workflow documents the rule. libzstd-size returns to ubuntu-latest. --- .github/workflows/dev-short-tests.yml | 18 ++++++++++++------ tests/check_size.py | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index 6610a7a16ae..a9f7ab9c298 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -339,19 +339,25 @@ jobs: /t:Clean,Build /p:Platform=${{matrix.platform}} /p:Configuration=${{matrix.configuration}} /warnaserror /p:InstructionSet=${{matrix.arch}} - # This tests that we don't accidentally grow the size too much. + # This tests that we don't accidentally grow the size too much, + # for example when a size reduction option stops working (#3066). # If the size grows intentionally, you can raise these numbers. # But we do need to think about binary size, since it is a concern. + # Each limit is the size measured with GCC 15.2 on 2026-11-12, plus about 30%, rounded up. + # -march=x86-64 makes the size independent of the compiler's default target: + # GCC 15 on ubuntu-26.04 runners targets x86-64-v3, which makes libzstd about 20% larger. + # When a limit is exceeded, check whether the growth comes from the code or the compiler, + # then measure again (check_size.py prints the sizes) and reset all limits with this rule. libzstd-size: - runs-on: ubuntu-24.04 # fails on ubuntu-26.04: libzstd.so is larger than the size limit + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: libzstd size test run: | - make clean && make -j -C lib libzstd && ./tests/check_size.py lib/libzstd.so 1100000 - make clean && make -j -C lib libzstd ZSTD_LIB_COMPRESSION=0 ZSTD_LIB_DICTBUILDER=0 && ./tests/check_size.py lib/libzstd.so 400000 - make clean && make -j -C lib libzstd ZSTD_LIB_MINIFY=1 && ./tests/check_size.py lib/libzstd.so 300000 - make clean && make -j -C lib libzstd ZSTD_LIB_MINIFY=1 ZSTD_LIB_COMPRESSION=0 ZSTD_LIB_DICTBUILDER=0 && ./tests/check_size.py lib/libzstd.so 80000 + make clean && make -j -C lib libzstd MOREFLAGS=-march=x86-64 && ./tests/check_size.py lib/libzstd.so 1200000 + make clean && make -j -C lib libzstd MOREFLAGS=-march=x86-64 ZSTD_LIB_COMPRESSION=0 ZSTD_LIB_DICTBUILDER=0 && ./tests/check_size.py lib/libzstd.so 260000 + make clean && make -j -C lib libzstd MOREFLAGS=-march=x86-64 ZSTD_LIB_MINIFY=1 && ./tests/check_size.py lib/libzstd.so 390000 + make clean && make -j -C lib libzstd MOREFLAGS=-march=x86-64 ZSTD_LIB_MINIFY=1 ZSTD_LIB_COMPRESSION=0 ZSTD_LIB_DICTBUILDER=0 && ./tests/check_size.py lib/libzstd.so 100000 minimal-decompressor-macros: runs-on: ubuntu-latest diff --git a/tests/check_size.py b/tests/check_size.py index 028b0a9c07c..9bfdeb8c99b 100755 --- a/tests/check_size.py +++ b/tests/check_size.py @@ -25,6 +25,7 @@ sys.exit(1) size = os.path.getsize(file) +print(f"{file} is {size} bytes, {100 * size / limit:.0f}% of the limit of {limit} bytes") if size > limit: print(f"file {file} is {size} bytes, which is greater than the limit of {limit} bytes") From 57bed05f13c3010f427f331db73b77d622d0a05b Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Thu, 12 Nov 2026 17:38:52 -0800 Subject: [PATCH 78/94] ci: test the BMI2 variants from a target without BMI2 GCC on ubuntu-26.04 runners targets x86-64-v3, which enables BMI2 by default. The dynamic-bmi2 cases without -mbmi2 then still had BMI2, so the generic code path and the runtime dispatch were no longer tested. Each case now starts from -march=x86-64. --- .github/workflows/dev-short-tests.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml index a9f7ab9c298..33ef26a8325 100644 --- a/.github/workflows/dev-short-tests.yml +++ b/.github/workflows/dev-short-tests.yml @@ -376,17 +376,19 @@ jobs: make clean && make check ZSTD_LIB_EXCLUDE_COMPRESSORS_DFAST_AND_UP=1 MOREFLAGS="-Werror" make clean && make check ZSTD_LIB_EXCLUDE_COMPRESSORS_GREEDY_AND_UP=1 MOREFLAGS="-Werror" + # -march=x86-64 starts from a target without BMI2: + # GCC on ubuntu-26.04 runners targets x86-64-v3, which enables BMI2 by default. dynamic-bmi2: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # tag=v7.0.1 - name: dynamic bmi2 tests run: | - make clean && make -j check MOREFLAGS="-O0 -Werror -mbmi2" - make clean && make -j check MOREFLAGS="-O0 -Werror -DDYNAMIC_BMI2=1" - make clean && make -j check MOREFLAGS="-O0 -Werror -DDYNAMIC_BMI2=1 -mbmi2" - make clean && make -j check MOREFLAGS="-O0 -Werror -DDYNAMIC_BMI2=0" - make clean && make -j check MOREFLAGS="-O0 -Werror -DDYNAMIC_BMI2=0 -mbmi2" + make clean && make -j check MOREFLAGS="-O0 -Werror -march=x86-64 -mbmi2" + make clean && make -j check MOREFLAGS="-O0 -Werror -march=x86-64 -DDYNAMIC_BMI2=1" + make clean && make -j check MOREFLAGS="-O0 -Werror -march=x86-64 -DDYNAMIC_BMI2=1 -mbmi2" + make clean && make -j check MOREFLAGS="-O0 -Werror -march=x86-64 -DDYNAMIC_BMI2=0" + make clean && make -j check MOREFLAGS="-O0 -Werror -march=x86-64 -DDYNAMIC_BMI2=0 -mbmi2" test-variants: runs-on: ubuntu-latest From 5a01e8f23681bf24d88a601c8e4a646978edbe9b Mon Sep 17 00:00:00 2001 From: kevinjzhang <47681004+kevinjzhang@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:19:17 -0800 Subject: [PATCH 79/94] Fix 32-bit zstdmt round buffer size overflow (#4948) On 32-bit builds, ZSTDMT_initCStream_internal() computed the round buffer size in size_t. The total can reach or exceed 4 GiB and wrap: - windowLog 30 with btultra2 and LDM disabled makes the overlap 1 GiB, which raises the job size to 1 GiB after the job size cap is applied. The 4 GiB total wraps to 0, the allocation is skipped, and the first streaming copy writes through a NULL buffer. - Many workers with large jobs wrap to a smaller non-zero size, so the buffer is allocated but is too small for the configuration. Compute the slack/section totals in 64 bits and reject the configuration with memory_allocation when the total exceeds SIZE_MAX, before allocating. Add a 32-bit-only fuzzer regression test that expects both cases to fail cleanly with memory_allocation. Co-authored-by: Kevin Zhang --- lib/compress/zstdmt_compress.c | 16 ++++++++++---- tests/fuzzer.c | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/lib/compress/zstdmt_compress.c b/lib/compress/zstdmt_compress.c index 38ad31d0e0d..5d94525dfeb 100644 --- a/lib/compress/zstdmt_compress.c +++ b/lib/compress/zstdmt_compress.c @@ -1331,11 +1331,19 @@ size_t ZSTDMT_initCStream_internal( * with the LDM window. */ size_t const nbSlackBuffers = 2 + (mtctx->targetPrefixSize > 0); - size_t const slackSize = mtctx->targetSectionSize * nbSlackBuffers; + U64 const slackSize = (U64)mtctx->targetSectionSize * nbSlackBuffers; /* Compute the total size, and always have enough slack */ - size_t const nbWorkers = MAX(mtctx->params.nbWorkers, 1); - size_t const sectionsSize = mtctx->targetSectionSize * nbWorkers; - size_t const capacity = MAX(windowSize, sectionsSize) + slackSize; + U64 const nbWorkers = MAX(mtctx->params.nbWorkers, 1); + U64 const sectionsSize = (U64)mtctx->targetSectionSize * nbWorkers; + U64 const capacity64 = MAX((U64)windowSize, sectionsSize) + slackSize; + size_t capacity; + /* The totals are computed in 64 bits because on 32-bit builds they + * can exceed the range of size_t. They would wrap, and the round + * buffer would be too small (or not allocated at all), letting + * input be copied past its end. Reject such sizes instead. */ + capacity = (size_t)capacity64; + RETURN_ERROR_IF((U64)capacity != capacity64, + memory_allocation, "round buffer size overflow"); if (mtctx->roundBuff.capacity < capacity) { if (mtctx->roundBuff.buffer) ZSTD_customFree(mtctx->roundBuff.buffer, mtctx->cMem); diff --git a/tests/fuzzer.c b/tests/fuzzer.c index 74c084e7219..5196c0f82b0 100644 --- a/tests/fuzzer.c +++ b/tests/fuzzer.c @@ -5671,6 +5671,46 @@ static int basicUnitTests(U32 const seed, double compressibility) } } + DISPLAYLEVEL(3, "test%3i : oversized round buffer must fail cleanly on 32-bit : ", testNb++); + /* On 32-bit builds the round buffer size can exceed 4 GiB, which + * wraps in 32-bit size_t. Without the overflow check in + * ZSTDMT_initCStream_internal(), init succeeds with a buffer that + * is too small for the requested configuration (and if it wraps + * to 0, the first streaming copy overruns it). With the check, + * the first ZSTD_compressStream2() call fails cleanly with + * memory_allocation and nothing is copied. Both cases are + * unreachable on 64-bit builds, where they get a real buffer. + * - case 0: windowLog 30 with btultra2 (level 22) and LDM disabled + * makes the overlap prefix 1 GiB, which bumps the job size to + * 1 GiB; 1 worker needs 4 GiB, which wraps to 0. + * - case 1: 16 workers with 256 MB jobs need 4.75 GiB, which + * wraps to 768 MB. */ + if (MEM_32bits()) { +#ifdef ZSTD_MULTITHREAD + struct { int nbWorkers; int windowLog; int jobSize; } const cases[2] = { + { 1, 30, 0 }, + { 16, 27, 256 MB }, + }; + size_t caseNb; + for (caseNb = 0; caseNb < 2; caseNb++) { + ZSTD_CCtx* const cctx = ZSTD_createCCtx(); + ZSTD_outBuffer out = { compressedBuffer, compressedBufferSize, 0 }; + ZSTD_inBuffer in = { CNBuffer, CNBuffSize, 0 }; + size_t r; + CHECK(cctx != NULL); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_nbWorkers, cases[caseNb].nbWorkers) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, ZSTD_maxCLevel()) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_windowLog, cases[caseNb].windowLog) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_jobSize, cases[caseNb].jobSize) ); + CHECK_Z( ZSTD_CCtx_setParameter(cctx, ZSTD_c_enableLongDistanceMatching, ZSTD_ps_disable) ); + r = ZSTD_compressStream2(cctx, &out, &in, ZSTD_e_continue); + ZSTD_freeCCtx(cctx); + if (ZSTD_getErrorCode(r) != ZSTD_error_memory_allocation) goto _output_error; + } +#endif + } + DISPLAYLEVEL(3, "OK \n"); + /* advanced parameters for decompression */ { ZSTD_DCtx* const dctx = ZSTD_createDCtx(); assert(dctx != NULL); From 07490bdc421f81e9a76795fcbf948052c7a9e40f Mon Sep 17 00:00:00 2001 From: kevinjzhang <47681004+kevinjzhang@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:19:36 -0800 Subject: [PATCH 80/94] Reduce 32-bit zstdmt maximum job size to 256 MB (#4946) On 32-bit builds, a level 22 stream with nbWorkers=1 needs a 2 GiB round buffer (4 x 512 MB), which malloc cannot provide in a 32-bit address space, so compression fails with memory_allocation. Lower ZSTDMT_JOBLOG_MAX from 29 to 28 and ZSTDMT_JOBSIZE_MAX from 512 MB to 256 MB on 32-bit builds, so the same case needs 1 GiB. 64-bit builds are unchanged. Addresses #4628. Co-authored-by: Kevin Zhang --- lib/compress/zstdmt_compress.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/compress/zstdmt_compress.h b/lib/compress/zstdmt_compress.h index 3e2ee664ed0..eee96122f57 100644 --- a/lib/compress/zstdmt_compress.h +++ b/lib/compress/zstdmt_compress.h @@ -32,8 +32,8 @@ #ifndef ZSTDMT_JOBSIZE_MIN /* a different value can be selected at compile time */ # define ZSTDMT_JOBSIZE_MIN (512 KB) #endif -#define ZSTDMT_JOBLOG_MAX (MEM_32bits() ? 29 : 30) -#define ZSTDMT_JOBSIZE_MAX (MEM_32bits() ? (512 MB) : (1024 MB)) +#define ZSTDMT_JOBLOG_MAX (MEM_32bits() ? 28 : 30) +#define ZSTDMT_JOBSIZE_MAX (MEM_32bits() ? (256 MB) : (1024 MB)) /* ======================================================== From baaa8ab5d0e5024b261dd5c4ea9d4b1bf7fdb88f Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Thu, 12 Nov 2026 14:27:17 -0800 Subject: [PATCH 81/94] programs: reject incomplete skippable headers in list mode List mode read the skippable length before checking that all eight header bytes were available, allowing stale stack bytes to affect a printed seek position. Reject truncated headers before reading the length and cover all magic values and short lengths, while preserving the signed 64-bit seek regression. --- programs/fileio.c | 12 ++-- tests/cli-tests/basic/list-short-skippable.sh | 65 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100755 tests/cli-tests/basic/list-short-skippable.sh diff --git a/programs/fileio.c b/programs/fileio.c index 20f99530cf2..b22d1b49f0c 100644 --- a/programs/fileio.c +++ b/programs/fileio.c @@ -3549,10 +3549,14 @@ FIO_analyzeFrames(fileInfo_t* info, FILE* const srcFile) } /* Skippable frame */ else if ((magicNumber & ZSTD_MAGIC_SKIPPABLE_MASK) == ZSTD_MAGIC_SKIPPABLE_START) { - U32 const frameSize = MEM_readLE32(headerBuffer + 4); - S64 const seek = (S64)ZSTD_SKIPPABLEHEADERSIZE + frameSize - (S64)numBytesRead; - ERROR_IF(LONG_SEEK(srcFile, seek, SEEK_CUR) != 0, - info_frame_error, "Error: could not find end of skippable frame"); + /* The generic minimum-header check permits six or seven bytes. */ + ERROR_IF(numBytesRead < ZSTD_SKIPPABLEHEADERSIZE, info_truncated_input, + "Error: reached end of file with incomplete skippable frame"); + { U32 const frameSize = MEM_readLE32(headerBuffer + 4); + S64 const seek = (S64)ZSTD_SKIPPABLEHEADERSIZE + frameSize - (S64)numBytesRead; + ERROR_IF(LONG_SEEK(srcFile, seek, SEEK_CUR) != 0, + info_frame_error, "Error: could not find end of skippable frame"); + } info->numSkippableFrames++; } /* unknown content */ diff --git a/tests/cli-tests/basic/list-short-skippable.sh b/tests/cli-tests/basic/list-short-skippable.sh new file mode 100755 index 00000000000..629887d6ab3 --- /dev/null +++ b/tests/cli-tests/basic/list-short-skippable.sh @@ -0,0 +1,65 @@ +#!/bin/sh + +set -eu + +# A complete frame whose dictionary-ID bytes leave known values in the list +# parser's header buffer. Listing does not need the referenced dictionary. +# Layout: magic, single-segment descriptor, dictID 11 22 de ad, FCS 4, +# and a final raw block containing four bytes. +printf '\050\265\057\375\043\021\042\336\255\004\041\000\000AAAA' > prefix.zst + +check_incomplete_header() { + if zstd -l "$1" > list.stdout 2> list.stderr; then + die "list accepted an incomplete skippable header: $1" + fi + grep -Fq 'incomplete' list.stderr + if grep -Fq 'seeked to position' list.stderr; then + die "list used a length from an incomplete header: $1" + fi + if grep -Eq 'AddressSanitizer|UndefinedBehaviorSanitizer|runtime error:' list.stderr; then + die "sanitizer failure while listing $1" + fi +} + +# An empty file is incomplete, but no next-frame bytes after a complete frame +# is a valid boundary. +printf '' > short.zst +check_incomplete_header short.zst +zstd -l prefix.zst > /dev/null + +# Exercise the lowest and highest skippable magics and every nonempty +# truncated header, both at the start of a file and after a frame that primes +# the header buffer. The loop values are octal escapes for the low magic byte, +# 0x50 and 0x5F. +for magic in 120 137; do + printf "\\$magic\052\115\030\000\000\000\000" > header.zst + for length in 1 2 3 4 5 6 7; do + dd if=header.zst of=short.zst bs=1 count="$length" 2>/dev/null + check_incomplete_header short.zst + cat prefix.zst short.zst > after.zst + check_incomplete_header after.zst + done + + # A complete eight-byte, zero-payload skippable frame is still valid. + zstd -l header.zst > /dev/null + cat prefix.zst header.zst > after.zst + zstd -l after.zst > /dev/null +done + +# Complete nonempty payloads still skip to the next frame. The short case +# requires seeking backward after read-ahead; the long case seeks forward. +printf '\120\052\115\030\004\000\000\000data' > short-payload.zst +printf '\137\052\115\030\040\000\000\0000123456789abcdef0123456789abcdef' > long-payload.zst +for payload in short-payload.zst long-payload.zst; do + zstd -l "$payload" > /dev/null + cat "$payload" prefix.zst > after.zst + zstd -l after.zst > /dev/null +done + +# Preserve the separate signed-64-bit seek fix: this complete header declares +# a payload extending to position 2^32, and must fail without seeking backward. +printf '\120\052\115\030\370\377\377\377' > large.zst +if zstd -l large.zst > list.stdout 2> list.stderr; then + die 'list accepted an incomplete large skippable frame' +fi +grep -Fq 'seeked to position 4294967296' list.stderr From e6285083fdc88357dd03acc5be16879d3c4a61b6 Mon Sep 17 00:00:00 2001 From: YoungjuKim Date: Sat, 17 Oct 2026 03:18:22 +0900 Subject: [PATCH 82/94] man: fix default thread counts for -T#, ZSTD_NBTHREADS and benchmark mode - -T# default is ZSTDCLI_NBTHREADS_DEFAULT, based on logical cores (UTIL_countLogicalCores() / 4, since #4211), not physical cores. - ZSTDMT_NBWORKERS_MAX is 64 on 32-bit and 256 on 64-bit, not 200. - Benchmark mode uses 1 worker thread by default since 725a152. Signed-off-by: YoungjuKim --- programs/zstd.1 | 6 +++--- programs/zstd.1.md | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/programs/zstd.1 b/programs/zstd.1 index e49a182d506..101a72580c3 100644 --- a/programs/zstd.1 +++ b/programs/zstd.1 @@ -108,7 +108,7 @@ Display information related to a zstd compressed file, such as size, ratio, and \fB\-\-fast[=#]\fR: switch to ultra\-fast compression levels\. If \fB=#\fR is not present, it defaults to \fB1\fR\. The higher the value, the faster the compression speed, at the cost of some compression ratio\. This setting overwrites compression level if one was set previously\. Similarly, if a compression level is set after \fB\-\-fast\fR, it overrides it\. . .IP "\(bu" 4 -\fB\-T#\fR, \fB\-\-threads=#\fR: Compress using \fB#\fR working threads (default: between 1 and 4 depending on physical CPU cores; see \fBZSTD_NBTHREADS\fR below)\. If \fB#\fR is 0, attempt to detect and use the number of physical CPU cores\. In all cases, the nb of threads is capped to \fBZSTDMT_NBWORKERS_MAX\fR, which is either 64 in 32\-bit mode, or 256 for 64\-bit environments\. This modifier does nothing if \fBzstd\fR is compiled without multithread support\. +\fB\-T#\fR, \fB\-\-threads=#\fR: Compress using \fB#\fR working threads (default: between 1 and 4 depending on the number of logical CPU cores; see \fBZSTD_NBTHREADS\fR below)\. If \fB#\fR is 0, attempt to detect and use the number of physical CPU cores\. In all cases, the nb of threads is capped to \fBZSTDMT_NBWORKERS_MAX\fR, which is either 64 in 32\-bit mode, or 256 for 64\-bit environments\. This modifier does nothing if \fBzstd\fR is compiled without multithread support\. . .IP "\(bu" 4 \fB\-\-single\-thread\fR: Use a single thread for both I/O and compression\. As compression is serialized with I/O, this can be slightly slower\. Single\-thread mode features significantly lower memory usage, which can be useful for systems with limited amount of memory, such as 32\-bit systems\. @@ -268,7 +268,7 @@ Employing environment variables to set parameters has security implications\. Th \fBZSTD_CLEVEL\fR can be used to set the level between 1 and 19 (the "normal" range)\. If the value of \fBZSTD_CLEVEL\fR is not a valid integer, it will be ignored with a warning message\. \fBZSTD_CLEVEL\fR just replaces the default compression level (\fB3\fR)\. . .P -\fBZSTD_NBTHREADS\fR can be used to set the number of threads \fBzstd\fR will attempt to use during compression\. If the value of \fBZSTD_NBTHREADS\fR is not a valid unsigned integer, it will be ignored with a warning message\. \fBZSTD_NBTHREADS\fR has a default value of \fBmax(1, min(4, nbCores/4))\fR, and is capped at ZSTDMT_NBWORKERS_MAX==200\. \fBzstd\fR must be compiled with multithread support for this variable to have any effect\. +\fBZSTD_NBTHREADS\fR can be used to set the number of threads \fBzstd\fR will attempt to use during compression\. If the value of \fBZSTD_NBTHREADS\fR is not a valid unsigned integer, it will be ignored with a warning message\. \fBZSTD_NBTHREADS\fR has a default value of \fBmax(1, min(4, nbLogicalCores/4))\fR, and is capped at \fBZSTDMT_NBWORKERS_MAX\fR, which is either 64 in 32\-bit mode, or 256 for 64\-bit environments\. \fBzstd\fR must be compiled with multithread support for this variable to have any effect\. . .P They can both be overridden by corresponding command line arguments: \fB\-#\fR for compression level and \fB\-T#\fR for number of compression threads\. @@ -530,7 +530,7 @@ The \fBzstd\fR CLI provides a benchmarking mode that can be used to easily find It\'s possible to pass multiple files to the benchmark, and even a directory with \fB\-r DIRECTORY\fR\. When no \fBFILE\fR is provided, the benchmark will use a procedurally generated \fBlorem ipsum\fR text\. . .P -Benchmarking will employ \fBmax(1, min(4, nbCores/4))\fR worker threads by default in order to match the behavior of the normal CLI I/O\. +Benchmarking will employ 1 worker thread by default\. Use \fB\-T#\fR to benchmark with more worker threads\. . .IP "\(bu" 4 \fB\-b#\fR: benchmark file(s) using compression level # diff --git a/programs/zstd.1.md b/programs/zstd.1.md index 74cd039544a..1bf8447ad96 100644 --- a/programs/zstd.1.md +++ b/programs/zstd.1.md @@ -126,7 +126,7 @@ the last one takes effect. This setting overwrites compression level if one was set previously. Similarly, if a compression level is set after `--fast`, it overrides it. * `-T#`, `--threads=#`: - Compress using `#` working threads (default: between 1 and 4 depending on physical CPU cores; see `ZSTD_NBTHREADS` below). + Compress using `#` working threads (default: between 1 and 4 depending on the number of logical CPU cores; see `ZSTD_NBTHREADS` below). If `#` is 0, attempt to detect and use the number of physical CPU cores. In all cases, the nb of threads is capped to `ZSTDMT_NBWORKERS_MAX`, which is either 64 in 32-bit mode, or 256 for 64-bit environments. @@ -348,7 +348,8 @@ If the value of `ZSTD_CLEVEL` is not a valid integer, it will be ignored with a `ZSTD_NBTHREADS` can be used to set the number of threads `zstd` will attempt to use during compression. If the value of `ZSTD_NBTHREADS` is not a valid unsigned integer, it will be ignored with a warning message. -`ZSTD_NBTHREADS` has a default value of `max(1, min(4, nbCores/4))`, and is capped at ZSTDMT_NBWORKERS_MAX==200. +`ZSTD_NBTHREADS` has a default value of `max(1, min(4, nbLogicalCores/4))`, and is capped at `ZSTDMT_NBWORKERS_MAX`, +which is either 64 in 32-bit mode, or 256 for 64-bit environments. `zstd` must be compiled with multithread support for this variable to have any effect. They can both be overridden by corresponding command line arguments: @@ -674,7 +675,7 @@ Note that results are very dependent on the content being compressed. It's possible to pass multiple files to the benchmark, and even a directory with `-r DIRECTORY`. When no `FILE` is provided, the benchmark will use a procedurally generated `lorem ipsum` text. -Benchmarking will employ `max(1, min(4, nbCores/4))` worker threads by default in order to match the behavior of the normal CLI I/O. +Benchmarking will employ 1 worker thread by default. Use `-T#` to benchmark with more worker threads. * `-b#`: benchmark file(s) using compression level # From 8cda638f0f4a6447c311d5132a5f538b2a5d3aba Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Fri, 13 Nov 2026 14:11:30 -0800 Subject: [PATCH 83/94] ci: install gcc-7 and gcc-8 without the toolchain PPA The four jobs that test gcc-7 and gcc-8 install them from the focal archive. They also added ppa:ubuntu-toolchain-r/test through make gcc7install and gcc8install, but no package came from it: it only cost about 10 s per job, and a Launchpad SSL error failed one job on #4949. The jobs now install the same packages with apt-get directly. The make targets are unchanged, for the machines that still need the PPA. --- .github/workflows/dev-long-tests.yml | 4 ++-- .github/workflows/dev-short-tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-long-tests.yml b/.github/workflows/dev-long-tests.yml index f85b7a7bab3..550a9c2939a 100644 --- a/.github/workflows/dev-long-tests.yml +++ b/.github/workflows/dev-long-tests.yml @@ -119,7 +119,7 @@ jobs: sudo sysctl -w vm.mmap_rnd_bits=28 echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu focal main universe" | sudo tee -a /etc/apt/sources.list sudo apt-get -qqq update - make gcc8install + sudo apt-get -yq --no-install-suggests --no-install-recommends install libc6-dev-i386 gcc-multilib gcc-8 gcc-8-multilib CC=gcc-8 make -j uasan-test-zstd Date: Wed, 10 Jun 2026 16:38:37 -0400 Subject: [PATCH 84/94] Document --max large-window decompression --- programs/README.md | 3 ++- programs/zstd.1 | 4 ++-- programs/zstd.1.md | 8 ++++---- programs/zstdcli.c | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/programs/README.md b/programs/README.md index 268bdc52357..8521cba439c 100644 --- a/programs/README.md +++ b/programs/README.md @@ -202,12 +202,13 @@ Advanced options: Advanced compression options: --ultra Enable levels beyond 19, up to 22; requires more memory. + --max Enable the maximum compression settings. --fast[=#] Use to very fast compression levels. [Default: 1] --adapt Dynamically adapt compression level to I/O conditions. --long[=#] Enable long distance matching with window log #. [Default: 27] --patch-from=REF Use REF as the reference point for Zstandard's diff engine. - -T# Spawn # compression threads. [Default: 1; pass 0 for core count.] + -T# Spawn # compression threads. [Default: 1-4, depending on physical cores; pass 0 for core count.] --single-thread Share a single thread for I/O and compression (slightly different than `-T1`). --auto-threads={physical|logical} Use physical/logical cores when using `-T0`. [Default: Physical] diff --git a/programs/zstd.1 b/programs/zstd.1 index 101a72580c3..cdaee14ac83 100644 --- a/programs/zstd.1 +++ b/programs/zstd.1 @@ -132,7 +132,7 @@ Note 2: this mode is different from \fB\-T1\fR, which spawns 1 compression threa \fB\-\-long[=#]\fR: enables long distance matching with \fB#\fR \fBwindowLog\fR, if \fB#\fR is not present it defaults to \fB27\fR\. This increases the window size (\fBwindowLog\fR) and memory usage for both the compressor and decompressor\. This setting is designed to improve the compression ratio for files with long matches at a large distance\. . .IP -Note: If \fBwindowLog\fR is set to larger than 27, \fB\-\-long=windowLog\fR or \fB\-\-memory=windowSize\fR needs to be passed to the decompressor\. +Note: If \fBwindowLog\fR is set to larger than 27, \fB\-\-long=windowLog\fR, \fB\-\-max\fR, or \fB\-\-memory=windowSize\fR needs to be passed to the decompressor\. . .IP "\(bu" 4 \fB\-\-max\fR: set advanced parameters to maximum compression\. warning: this setting is very slow and uses a lot of resources\. It\'s inappropriate for 32\-bit mode and therefore disabled in this mode\. @@ -294,7 +294,7 @@ Specify the maximum number of bits for a match distance\. The higher number of increases the chance to find a match which usually improves compression ratio\. It also increases memory requirements for the compressor and decompressor\. The minimum \fIwlog\fR is 10 (1 KiB) and the maximum is 30 (1 GiB) on 32\-bit platforms and 31 (2 GiB) on 64\-bit platforms\. . .IP -Note: If \fBwindowLog\fR is set to larger than 27, \fB\-\-long=windowLog\fR or \fB\-\-memory=windowSize\fR needs to be passed to the decompressor\. +Note: If \fBwindowLog\fR is set to larger than 27, \fB\-\-long=windowLog\fR, \fB\-\-max\fR, or \fB\-\-memory=windowSize\fR needs to be passed to the decompressor\. . .TP \fBhashLog\fR=\fIhlog\fR, \fBhlog\fR=\fIhlog\fR diff --git a/programs/zstd.1.md b/programs/zstd.1.md index 1bf8447ad96..d46f395dc3f 100644 --- a/programs/zstd.1.md +++ b/programs/zstd.1.md @@ -164,8 +164,8 @@ the last one takes effect. This setting is designed to improve the compression ratio for files with long matches at a large distance. - Note: If `windowLog` is set to larger than 27, `--long=windowLog` or - `--memory=windowSize` needs to be passed to the decompressor. + Note: If `windowLog` is set to larger than 27, `--long=windowLog`, + `--max`, or `--memory=windowSize` needs to be passed to the decompressor. * `-D DICT`: use `DICT` as Dictionary to compress or decompress FILE(s) * `--patch-from FILE`: @@ -386,8 +386,8 @@ The list of available _options_: The minimum _wlog_ is 10 (1 KiB) and the maximum is 30 (1 GiB) on 32-bit platforms and 31 (2 GiB) on 64-bit platforms. - Note: If `windowLog` is set to larger than 27, `--long=windowLog` or - `--memory=windowSize` needs to be passed to the decompressor. + Note: If `windowLog` is set to larger than 27, `--long=windowLog`, + `--max`, or `--memory=windowSize` needs to be passed to the decompressor. - `hashLog`=_hlog_, `hlog`=_hlog_: Specify the maximum number of bits for a hash table. diff --git a/programs/zstdcli.c b/programs/zstdcli.c index d0821db9741..eef861722c2 100644 --- a/programs/zstdcli.c +++ b/programs/zstdcli.c @@ -225,6 +225,7 @@ static void usageAdvanced(const char* programName) DISPLAYOUT("\n"); DISPLAYOUT("Advanced compression options:\n"); DISPLAYOUT(" --ultra Enable levels beyond %i, up to %i; requires more memory.\n", ZSTDCLI_CLEVEL_MAX, ZSTD_maxCLevel()); + DISPLAYOUT(" --max Enable the maximum compression settings.\n"); DISPLAYOUT(" --fast[=#] Use to very fast compression levels. [Default: %u]\n", 1); #ifdef ZSTD_GZCOMPRESS if (exeNameMatch(programName, ZSTD_GZ)) { /* behave like gzip */ From 16b63fd430e53352cfdc3731ce7d0cb19cb2f6fc Mon Sep 17 00:00:00 2001 From: Jennifer Lee Date: Fri, 13 Nov 2026 15:20:46 -0800 Subject: [PATCH 85/94] update readme wording --- programs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/README.md b/programs/README.md index 8521cba439c..49a9db70641 100644 --- a/programs/README.md +++ b/programs/README.md @@ -208,7 +208,7 @@ Advanced compression options: --long[=#] Enable long distance matching with window log #. [Default: 27] --patch-from=REF Use REF as the reference point for Zstandard's diff engine. - -T# Spawn # compression threads. [Default: 1-4, depending on physical cores; pass 0 for core count.] + -T# Spawn # compression threads. [Default: 1-4, depending on logical cores; pass 0 for core count.] --single-thread Share a single thread for I/O and compression (slightly different than `-T1`). --auto-threads={physical|logical} Use physical/logical cores when using `-T0`. [Default: Physical] From 66d34c31ddc73205ad39ceab77c3c6eccb09d184 Mon Sep 17 00:00:00 2001 From: Siva Mahadevan Date: Tue, 7 Apr 2026 19:51:12 -0400 Subject: [PATCH 86/94] zstd_preSplit: change abs64 name to ZSTD_abs64 This avoids a lot of name collision issues with other projects. See https://github.com/openzfs/zfs/commit/a2ac9cd606ce2428c23cc89cec6f0392424e82c9 which made the same change downstream. --- lib/compress/zstd_preSplit.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/compress/zstd_preSplit.c b/lib/compress/zstd_preSplit.c index d820c20ac24..67ab89f02ce 100644 --- a/lib/compress/zstd_preSplit.c +++ b/lib/compress/zstd_preSplit.c @@ -90,7 +90,7 @@ ZSTD_GEN_RECORD_FINGERPRINT(11, 9) ZSTD_GEN_RECORD_FINGERPRINT(43, 8) -static U64 abs64(S64 s64) { return (U64)((s64 < 0) ? -s64 : s64); } +static U64 ZSTD_abs64(S64 s64) { return (U64)((s64 < 0) ? -s64 : s64); } static U64 fpDistance(const Fingerprint* fp1, const Fingerprint* fp2, unsigned hashLog) { @@ -99,7 +99,7 @@ static U64 fpDistance(const Fingerprint* fp1, const Fingerprint* fp2, unsigned h assert(hashLog <= HASHLOG_MAX); for (n = 0; n < ((size_t)1 << hashLog); n++) { distance += - abs64((S64)fp1->events[n] * (S64)fp2->nbEvents - (S64)fp2->events[n] * (S64)fp1->nbEvents); + ZSTD_abs64((S64)fp1->events[n] * (S64)fp2->nbEvents - (S64)fp2->events[n] * (S64)fp1->nbEvents); } return distance; } @@ -219,7 +219,7 @@ static size_t ZSTD_splitBlock_fromBorders(const void* blockStart, size_t blockSi { U64 const distFromBegin = fpDistance(&fpstats->pastEvents, middleEvents, 8); U64 const distFromEnd = fpDistance(&fpstats->newEvents, middleEvents, 8); U64 const minDistance = SEGMENT_SIZE * SEGMENT_SIZE / 3; - if (abs64((S64)distFromBegin - (S64)distFromEnd) < minDistance) + if (ZSTD_abs64((S64)distFromBegin - (S64)distFromEnd) < minDistance) return 64 KB; return (distFromBegin > distFromEnd) ? 32 KB : 96 KB; } From 854d1e0001294adef7324e6f437edf299acf54df Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Fri, 13 Nov 2026 12:33:52 -0800 Subject: [PATCH 87/94] programs: reject overflowing dictionary sample counts Bound the cumulative sample count by INT_MAX and the maximum allocatable size_t table length before narrowing or allocation. This prevents small-chunk training inputs from wrapping the allocation size on 32-bit builds. Add a fast sparse-file regression covering all trainers, partial chunks, and counts accumulated across files. --- programs/dibio.c | 14 ++++-- .../dict-builder/sample-count-overflow.sh | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100755 tests/cli-tests/dict-builder/sample-count-overflow.sh diff --git a/programs/dibio.c b/programs/dibio.c index dc629103ba2..245fb80e072 100644 --- a/programs/dibio.c +++ b/programs/dibio.c @@ -27,6 +27,7 @@ #include /* memset */ #include /* fprintf, fopen, ftello64 */ #include /* errno */ +#include /* INT_MAX */ #include "timefn.h" /* UTIL_time_t, UTIL_clockSpanMicro, UTIL_getTime */ #include "../lib/common/debug.h" /* assert */ @@ -270,6 +271,7 @@ typedef struct { */ static fileStats DiB_fileStats(const char** fileNamesTable, int nbFiles, size_t chunkSize, int displayLevel) { + size_t const maxNbSamples = MIN((size_t)INT_MAX, (size_t)-1 / sizeof(size_t)); fileStats fs; int n; memset(&fs, 0, sizeof(fs)); @@ -279,6 +281,7 @@ static fileStats DiB_fileStats(const char** fileNamesTable, int nbFiles, size_t for (n=0; n maxNbSamples - (size_t)fs.nbSamples) + EXM_THROW(14, "too many samples for dictionary training"); + fs.nbSamples += (int)fileNbSamples; + /* the case where we are breaking up files in sample chunks */ if (chunkSize > 0) { /* TODO: is there a minimum sample size? Can we have a 1-byte sample? */ - fs.nbSamples += (int)((fileSize + chunkSize-1) / chunkSize); fs.totalSizeToLoad += fileSize; } else { @@ -308,7 +317,6 @@ static fileStats DiB_fileStats(const char** fileNamesTable, int nbFiles, size_t DISPLAYLEVEL(3, "Sample file '%s' is too large, limiting to %d KB\n", fileNamesTable[n], SAMPLESIZE_MAX / (1 KB)); } - fs.nbSamples += 1; fs.totalSizeToLoad += MIN(fileSize, SAMPLESIZE_MAX); } } @@ -357,7 +365,7 @@ int DiB_trainFromFiles(const char* dictFileName, size_t maxDictSize, loadedSize = (size_t)MIN(loadedSize, memLimit); } srcBuffer = malloc(loadedSize+NOISELENGTH); - sampleSizes = (size_t*)malloc(fs.nbSamples * sizeof(size_t)); + sampleSizes = (size_t*)malloc((size_t)fs.nbSamples * sizeof(*sampleSizes)); } /* Checks */ diff --git a/tests/cli-tests/dict-builder/sample-count-overflow.sh b/tests/cli-tests/dict-builder/sample-count-overflow.sh new file mode 100755 index 00000000000..6fda5c077f0 --- /dev/null +++ b/tests/cli-tests/dict-builder/sample-count-overflow.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +set -eu + +# The count must fit both an int and a size_t array allocation. Query the +# tested binary, since the test runner may be executing a 32-bit build. +case "$(zstd -V)" in + *32-bit*) max_samples=1073741823 ;; + *) max_samples=2147483647 ;; +esac + +printf '%s\n' 'Error 14 : too many samples for dictionary training' > expected.stderr + +make_sparse_file() { + dd if=/dev/zero of="$1" bs=1 count=0 seek="$2" 2>/dev/null +} + +check_too_many_samples() { + status=0 + zstd -q --train --memory=1MB "$@" -o dictionary > actual.stdout 2> actual.stderr || status=$? + test "$status" -eq 14 || die "expected sample-count rejection, got exit $status: $*" + test ! -s actual.stdout || die 'unexpected dictionary-training output' + cmp expected.stderr actual.stderr + test ! -e dictionary || die 'oversized sample set produced a dictionary' +} + +# The first count above the limit. On 32-bit builds this is the original +# 1 GiB / -B1 reproducer, whose sample-table allocation wrapped to zero. +make_sparse_file sample "$((max_samples + 1))" +for trainer in --train --train-cover --train-fastcover --train-legacy; do + check_too_many_samples "$trainer" -B1 sample +done + +# A nonzero wrapped allocation is unsafe too. +make_sparse_file sample "$((max_samples + 2))" +check_too_many_samples --split=1 sample + +# Include the last partial chunk when checking the limit. +make_sparse_file sample "$((max_samples * 2 + 1))" +check_too_many_samples --split=2 sample + +# Individually valid counts must also be checked when they are added. +make_sparse_file sample "$max_samples" +make_sparse_file extra 1 +check_too_many_samples --split=1 sample extra From 440519767457a6ad4a6c83e2e9feefa049db21ae Mon Sep 17 00:00:00 2001 From: Saleem Malik Date: Thu, 23 Jul 2026 13:50:00 +0530 Subject: [PATCH 88/94] fix result buffer overrun in decode-only benchmark --- programs/benchzstd.c | 2 +- tests/playTests.sh | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/programs/benchzstd.c b/programs/benchzstd.c index f55c8697504..4bc300b81b6 100644 --- a/programs/benchzstd.c +++ b/programs/benchzstd.c @@ -517,7 +517,7 @@ static BMK_benchOutcome_t BMK_benchMemAdvancedNoAlloc( : chunkSize; srcPtr += chunkSize; cPtr += cCapacities[chunkID]; - resPtr += chunkSize; + resPtr += resSizes[chunkID]; remaining -= chunkSize; if (adv->mode == BMK_decodeOnly) { cSizes[chunkID] = chunkSize; diff --git a/tests/playTests.sh b/tests/playTests.sh index ba0500dee3a..a9054ae90d5 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -1350,6 +1350,11 @@ zstd -f tmp1 zstd -b -d -i0 tmp1.zst println "benchmark can fail - decompression on invalid data" zstd -b -d -i0 tmp1 && die "invalid .zst data => benchmark should have failed" +println "benchmark decompression of several frames whose content is smaller than the frame" +printf 'a' > tmpbd1 ; printf 'b' > tmpbd2 ; printf 'c' > tmpbd3 +zstd -qf tmpbd1 tmpbd2 tmpbd3 +zstd -b -d -i0 tmpbd1.zst tmpbd2.zst tmpbd3.zst +rm -f tmpbd1 tmpbd2 tmpbd3 tmpbd1.zst tmpbd2.zst tmpbd3.zst GZIPMODE=1 zstd --format=gzip -V || GZIPMODE=0 From b7a09933c62d23a8093dd438d70db0cd26d97b5e Mon Sep 17 00:00:00 2001 From: xhon-pelushi Date: Wed, 30 Sep 2026 17:03:17 -0400 Subject: [PATCH 89/94] benchfn: don't memset a NULL dst buffer of size 0 Some fullbench scenarios (convertSequences, get1BlockSummary) do not need a destination buffer, so their preparation leaves dst == NULL and dstCapacity == 0. BMK_benchFunction() then called memset(NULL, 0xE5, 0). Passing a null pointer to memset() is undefined behavior even when the length is 0, and UBSan reports it through glibc's nonnull annotation: programs/benchfn.c:116:13: runtime error: null pointer passed as argument 1, which is declared to never be null Skip the warm-up memset when the destination capacity is 0. Fixes #4776 --- programs/benchfn.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/programs/benchfn.c b/programs/benchfn.c index 3e042cf38f8..f31993c0a43 100644 --- a/programs/benchfn.c +++ b/programs/benchfn.c @@ -113,7 +113,8 @@ BMK_runOutcome_t BMK_benchFunction(BMK_benchParams_t p, /* init */ { size_t i; for(i = 0; i < p.blockCount; i++) { - memset(p.dstBuffers[i], 0xE5, p.dstCapacities[i]); /* warm up and erase result buffer */ + if (p.dstCapacities[i] > 0) /* dstBuffers[i] may be NULL when dstCapacities[i] == 0 */ + memset(p.dstBuffers[i], 0xE5, p.dstCapacities[i]); /* warm up and erase result buffer */ } } /* benchmark */ From f2347f44dfed52b91909fec60642a1cfe6f7b846 Mon Sep 17 00:00:00 2001 From: Nick Terrell Date: Fri, 13 Nov 2026 11:49:05 -0800 Subject: [PATCH 90/94] Remove deprecated ZSTD_copyCCtx() and ZSTD_copyDCtx() Both functions were deprecated in the experimental API, documented as misleading with very limited utility. Remove them along with the static ZSTD_copyCCtx_internal() helper, the manual entry, and the tests that exercised them. In the fuzzer, drop the context-duplication tests, and have the bufferless streaming test initialize its context directly instead of copying from a reference context. --- doc/zstd_manual.html | 2 - lib/compress/zstd_compress.c | 99 -------------------------------- lib/decompress/zstd_decompress.c | 7 --- lib/zstd.h | 6 -- tests/fuzzer.c | 59 ++----------------- 5 files changed, 4 insertions(+), 169 deletions(-) diff --git a/doc/zstd_manual.html b/doc/zstd_manual.html index 89f0761f492..31a4d146df4 100644 --- a/doc/zstd_manual.html +++ b/doc/zstd_manual.html @@ -2114,8 +2114,6 @@

Buffer-less streaming compression functions

ZSTD_DEPR
 ZSTD_DEPRECATED("The buffer-less API is deprecated in favor of the normal streaming API. See docs.")
 ZSTDLIB_STATIC_API size_t ZSTD_compressBegin_usingCDict(ZSTD_CCtx* cctx, const ZSTD_CDict* cdict); /**< note: fails if cdict==NULL */
 

-
size_t ZSTD_copyCCtx(ZSTD_CCtx* cctx, const ZSTD_CCtx* preparedCCtx, unsigned long long pledgedSrcSize); /**<  note: if pledgedSrcSize is not known, use ZSTD_CONTENTSIZE_UNKNOWN */
-

size_t ZSTD_compressBegin_advanced(ZSTD_CCtx* cctx, const void* dict, size_t dictSize, ZSTD_parameters params, unsigned long long pledgedSrcSize); /**< pledgedSrcSize : If srcSize is not known at init time, use ZSTD_CONTENTSIZE_UNKNOWN */
 

Buffer-less streaming decompression (synchronous mode)

diff --git a/lib/compress/zstd_compress.c b/lib/compress/zstd_compress.c
index 0d06a6ff256..3cb6f21e603 100644
--- a/lib/compress/zstd_compress.c
+++ b/lib/compress/zstd_compress.c
@@ -2570,105 +2570,6 @@ static size_t ZSTD_resetCCtx_usingCDict(ZSTD_CCtx* cctx,
     }
 }
 
-/*! ZSTD_copyCCtx_internal() :
- *  Duplicate an existing context `srcCCtx` into another one `dstCCtx`.
- *  Only works during stage ZSTDcs_init (i.e. after creation, but before first call to ZSTD_compressContinue()).
- *  The "context", in this case, refers to the hash and chain tables,
- *  entropy tables, and dictionary references.
- * `windowLog` value is enforced if != 0, otherwise value is copied from srcCCtx.
- * @return : 0, or an error code */
-static size_t ZSTD_copyCCtx_internal(ZSTD_CCtx* dstCCtx,
-                            const ZSTD_CCtx* srcCCtx,
-                            ZSTD_frameParameters fParams,
-                            U64 pledgedSrcSize,
-                            ZSTD_buffered_policy_e zbuff)
-{
-    RETURN_ERROR_IF(srcCCtx->stage!=ZSTDcs_init, stage_wrong,
-                    "Can't copy a ctx that's not in init stage.");
-    DEBUGLOG(5, "ZSTD_copyCCtx_internal");
-    ZSTD_memcpy(&dstCCtx->customMem, &srcCCtx->customMem, sizeof(ZSTD_customMem));
-    {   ZSTD_CCtx_params params = dstCCtx->requestedParams;
-        /* Copy only compression parameters related to tables. */
-        params.cParams = srcCCtx->appliedParams.cParams;
-        assert(srcCCtx->appliedParams.useRowMatchFinder != ZSTD_ps_auto);
-        assert(srcCCtx->appliedParams.postBlockSplitter != ZSTD_ps_auto);
-        assert(srcCCtx->appliedParams.ldmParams.enableLdm != ZSTD_ps_auto);
-        params.useRowMatchFinder = srcCCtx->appliedParams.useRowMatchFinder;
-        params.postBlockSplitter = srcCCtx->appliedParams.postBlockSplitter;
-        params.ldmParams = srcCCtx->appliedParams.ldmParams;
-        params.fParams = fParams;
-        params.maxBlockSize = srcCCtx->appliedParams.maxBlockSize;
-        ZSTD_resetCCtx_internal(dstCCtx, ¶ms, pledgedSrcSize,
-                                /* loadedDictSize */ 0,
-                                ZSTDcrp_leaveDirty, zbuff);
-        assert(dstCCtx->appliedParams.cParams.windowLog == srcCCtx->appliedParams.cParams.windowLog);
-        assert(dstCCtx->appliedParams.cParams.strategy == srcCCtx->appliedParams.cParams.strategy);
-        assert(dstCCtx->appliedParams.cParams.hashLog == srcCCtx->appliedParams.cParams.hashLog);
-        assert(dstCCtx->appliedParams.cParams.chainLog == srcCCtx->appliedParams.cParams.chainLog);
-        assert(dstCCtx->blockState.matchState.hashLog3 == srcCCtx->blockState.matchState.hashLog3);
-    }
-
-    ZSTD_cwksp_mark_tables_dirty(&dstCCtx->workspace);
-
-    /* copy tables */
-    {   size_t const chainSize = ZSTD_allocateChainTable(srcCCtx->appliedParams.cParams.strategy,
-                                                         srcCCtx->appliedParams.useRowMatchFinder,
-                                                         0 /* forDDSDict */)
-                                    ? ((size_t)1 << srcCCtx->appliedParams.cParams.chainLog)
-                                    : 0;
-        size_t const hSize =  (size_t)1 << srcCCtx->appliedParams.cParams.hashLog;
-        U32 const h3log = srcCCtx->blockState.matchState.hashLog3;
-        size_t const h3Size = h3log ? ((size_t)1 << h3log) : 0;
-
-        ZSTD_memcpy(dstCCtx->blockState.matchState.hashTable,
-               srcCCtx->blockState.matchState.hashTable,
-               hSize * sizeof(U32));
-        ZSTD_memcpy(dstCCtx->blockState.matchState.chainTable,
-               srcCCtx->blockState.matchState.chainTable,
-               chainSize * sizeof(U32));
-        ZSTD_memcpy(dstCCtx->blockState.matchState.hashTable3,
-               srcCCtx->blockState.matchState.hashTable3,
-               h3Size * sizeof(U32));
-    }
-
-    ZSTD_cwksp_mark_tables_clean(&dstCCtx->workspace);
-
-    /* copy dictionary offsets */
-    {
-        const ZSTD_MatchState_t* srcMatchState = &srcCCtx->blockState.matchState;
-        ZSTD_MatchState_t* dstMatchState = &dstCCtx->blockState.matchState;
-        dstMatchState->window       = srcMatchState->window;
-        dstMatchState->nextToUpdate = srcMatchState->nextToUpdate;
-        dstMatchState->loadedDictEnd= srcMatchState->loadedDictEnd;
-    }
-    dstCCtx->dictID = srcCCtx->dictID;
-    dstCCtx->dictContentSize = srcCCtx->dictContentSize;
-
-    /* copy block state */
-    ZSTD_memcpy(dstCCtx->blockState.prevCBlock, srcCCtx->blockState.prevCBlock, sizeof(*srcCCtx->blockState.prevCBlock));
-
-    return 0;
-}
-
-/*! ZSTD_copyCCtx() :
- *  Duplicate an existing context `srcCCtx` into another one `dstCCtx`.
- *  Only works during stage ZSTDcs_init (i.e. after creation, but before first call to ZSTD_compressContinue()).
- *  pledgedSrcSize==0 means "unknown".
-*   @return : 0, or an error code */
-size_t ZSTD_copyCCtx(ZSTD_CCtx* dstCCtx, const ZSTD_CCtx* srcCCtx, unsigned long long pledgedSrcSize)
-{
-    ZSTD_frameParameters fParams = { 1 /*content*/, 0 /*checksum*/, 0 /*noDictID*/ };
-    ZSTD_buffered_policy_e const zbuff = srcCCtx->bufferedPolicy;
-    ZSTD_STATIC_ASSERT((U32)ZSTDb_buffered==1);
-    if (pledgedSrcSize==0) pledgedSrcSize = ZSTD_CONTENTSIZE_UNKNOWN;
-    fParams.contentSizeFlag = (pledgedSrcSize != ZSTD_CONTENTSIZE_UNKNOWN);
-
-    return ZSTD_copyCCtx_internal(dstCCtx, srcCCtx,
-                                fParams, pledgedSrcSize,
-                                zbuff);
-}
-
-
 #define ZSTD_ROWSIZE 16
 /*! ZSTD_reduceTable() :
  *  reduce table indexes by `reducerValue`, or squash to zero.
diff --git a/lib/decompress/zstd_decompress.c b/lib/decompress/zstd_decompress.c
index 6322d6cbb80..2a69ef11cb2 100644
--- a/lib/decompress/zstd_decompress.c
+++ b/lib/decompress/zstd_decompress.c
@@ -347,13 +347,6 @@ size_t ZSTD_freeDCtx(ZSTD_DCtx* dctx)
     }
 }
 
-/* no longer useful */
-void ZSTD_copyDCtx(ZSTD_DCtx* dstDCtx, const ZSTD_DCtx* srcDCtx)
-{
-    size_t const toCopy = (size_t)((char*)(&dstDCtx->inBuff) - (char*)dstDCtx);
-    ZSTD_memcpy(dstDCtx, srcDCtx, toCopy);  /* no need to copy workspace */
-}
-
 /* Look up the DDict for a complete, valid Zstandard frame header.
  * The result is for this frame only; it does not change the single-DDict state.
  *
diff --git a/lib/zstd.h b/lib/zstd.h
index cf6c7ee9e44..4bfff05c1f3 100644
--- a/lib/zstd.h
+++ b/lib/zstd.h
@@ -3064,10 +3064,6 @@ ZSTDLIB_STATIC_API size_t ZSTD_compressBegin_usingDict(ZSTD_CCtx* cctx, const vo
 ZSTD_DEPRECATED("The buffer-less API is deprecated in favor of the normal streaming API. See docs.")
 ZSTDLIB_STATIC_API size_t ZSTD_compressBegin_usingCDict(ZSTD_CCtx* cctx, const ZSTD_CDict* cdict); /**< note: fails if cdict==NULL */
 
-ZSTD_DEPRECATED("This function will likely be removed in a future release. It is misleading and has very limited utility.")
-ZSTDLIB_STATIC_API
-size_t ZSTD_copyCCtx(ZSTD_CCtx* cctx, const ZSTD_CCtx* preparedCCtx, unsigned long long pledgedSrcSize); /**<  note: if pledgedSrcSize is not known, use ZSTD_CONTENTSIZE_UNKNOWN */
-
 ZSTD_DEPRECATED("The buffer-less API is deprecated in favor of the normal streaming API. See docs.")
 ZSTDLIB_STATIC_API size_t ZSTD_compressContinue(ZSTD_CCtx* cctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize);
 ZSTD_DEPRECATED("The buffer-less API is deprecated in favor of the normal streaming API. See docs.")
@@ -3183,8 +3179,6 @@ ZSTDLIB_STATIC_API size_t ZSTD_nextSrcSizeToDecompress(ZSTD_DCtx* dctx);
 ZSTDLIB_STATIC_API size_t ZSTD_decompressContinue(ZSTD_DCtx* dctx, void* dst, size_t dstCapacity, const void* src, size_t srcSize);
 
 /* misc */
-ZSTD_DEPRECATED("This function will likely be removed in the next minor release. It is misleading and has very limited utility.")
-ZSTDLIB_STATIC_API void   ZSTD_copyDCtx(ZSTD_DCtx* dctx, const ZSTD_DCtx* preparedDCtx);
 typedef enum { ZSTDnit_frameHeader, ZSTDnit_blockHeader, ZSTDnit_block, ZSTDnit_lastBlock, ZSTDnit_checksum, ZSTDnit_skippableFrame } ZSTD_nextInputType_e;
 ZSTDLIB_STATIC_API ZSTD_nextInputType_e ZSTD_nextInputType(ZSTD_DCtx* dctx);
 
diff --git a/tests/fuzzer.c b/tests/fuzzer.c
index 5196c0f82b0..55ad99d0ccf 100644
--- a/tests/fuzzer.c
+++ b/tests/fuzzer.c
@@ -2458,13 +2458,10 @@ static int basicUnitTests(U32 const seed, double compressibility)
             ZSTD_getDictID_fromDDict;
         ZSTD_DStream* (* const funcptr_createDStream)(
             ZSTD_customMem customMem) = ZSTD_createDStream_advanced;
-        void (* const funcptr_copyDCtx)(
-            ZSTD_DCtx* dctx, const ZSTD_DCtx* preparedDCtx) = ZSTD_copyDCtx;
         ZSTD_nextInputType_e (* const funcptr_nextInputType)(ZSTD_DCtx* dctx) =
             ZSTD_nextInputType;
         const void *voidptr_getDictID;
         const void *voidptr_createDStream;
-        const void *voidptr_copyDCtx;
         const void *voidptr_nextInputType;
         DEBUG_STATIC_ASSERT(sizeof(funcptr_getDictID) == sizeof(voidptr_getDictID));
         memcpy(
@@ -2475,17 +2472,12 @@ static int basicUnitTests(U32 const seed, double compressibility)
             (void*)&voidptr_createDStream,
             (const void*)&funcptr_createDStream,
             sizeof(void*));
-        memcpy(
-            (void*)&voidptr_copyDCtx,
-            (const void*)&funcptr_copyDCtx,
-            sizeof(void*));
         memcpy(
             (void*)&voidptr_nextInputType,
             (const void*)&funcptr_nextInputType,
             sizeof(void*));
         DISPLAYLEVEL(3, "%p ", voidptr_getDictID);
         DISPLAYLEVEL(3, "%p ", voidptr_createDStream);
-        DISPLAYLEVEL(3, "%p ", voidptr_copyDCtx);
         DISPLAYLEVEL(3, "%p ", voidptr_nextInputType);
     }
     DISPLAYLEVEL(3, ": OK \n");
@@ -4224,19 +4216,12 @@ static int basicUnitTests(U32 const seed, double compressibility)
 
     /* Dictionary and CCtx Duplication tests */
     {   ZSTD_CCtx* const ctxOrig = ZSTD_createCCtx();
-        ZSTD_CCtx* const ctxDuplicated = ZSTD_createCCtx();
         ZSTD_DCtx* const dctx = ZSTD_createDCtx();
         static const size_t dictSize = 551;
-        assert(dctx != NULL); assert(ctxOrig != NULL); assert(ctxDuplicated != NULL);
-
-        DISPLAYLEVEL(3, "test%3i : copy context too soon : ", testNb++);
-        { size_t const copyResult = ZSTD_copyCCtx(ctxDuplicated, ctxOrig, 0);
-          if (!ZSTD_isError(copyResult)) goto _output_error; }   /* error must be detected */
-        DISPLAYLEVEL(3, "OK \n");
+        assert(dctx != NULL); assert(ctxOrig != NULL);
 
         DISPLAYLEVEL(3, "test%3i : load dictionary into context : ", testNb++);
         CHECK_Z( ZSTD_compressBegin_usingDict(ctxOrig, CNBuffer, dictSize, 2) );
-        CHECK_Z( ZSTD_copyCCtx(ctxDuplicated, ctxOrig, 0) ); /* Begin_usingDict implies unknown srcSize, so match that */
         DISPLAYLEVEL(3, "OK \n");
 
         DISPLAYLEVEL(3, "test%3i : compress with flat dictionary : ", testNb++);
@@ -4255,25 +4240,6 @@ static int basicUnitTests(U32 const seed, double compressibility)
                   if (r != CNBuffSize - dictSize) goto _output_error);
         DISPLAYLEVEL(3, "OK \n");
 
-        DISPLAYLEVEL(3, "test%3i : compress with duplicated context : ", testNb++);
-        {   size_t const cSizeOrig = cSize;
-            cSize = 0;
-            CHECKPLUS(r, ZSTD_compressEnd(ctxDuplicated,
-                                    compressedBuffer, compressedBufferSize,
-                       (const char*)CNBuffer + dictSize, CNBuffSize - dictSize),
-                      cSize += r);
-            if (cSize != cSizeOrig) goto _output_error;   /* should be identical ==> same size */
-        }
-        DISPLAYLEVEL(3, "OK (%u bytes : %.2f%%)\n", (unsigned)cSize, (double)cSize/CNBuffSize*100);
-
-        DISPLAYLEVEL(3, "test%3i : frame built with duplicated context should be decompressible : ", testNb++);
-        CHECKPLUS(r, ZSTD_decompress_usingDict(dctx,
-                                           decodedBuffer, CNBuffSize,
-                                           compressedBuffer, cSize,
-                                           CNBuffer, dictSize),
-                  if (r != CNBuffSize - dictSize) goto _output_error);
-        DISPLAYLEVEL(3, "OK \n");
-
         DISPLAYLEVEL(3, "test%3i : decompress with DDict : ", testNb++);
         {   ZSTD_DDict* const ddict = ZSTD_createDDict(CNBuffer, dictSize);
             size_t const r = ZSTD_decompress_usingDDict(dctx, decodedBuffer, CNBuffSize, compressedBuffer, cSize, ddict);
@@ -4294,19 +4260,6 @@ static int basicUnitTests(U32 const seed, double compressibility)
             DISPLAYLEVEL(3, "OK (size of static DDict : %u) \n", (unsigned)ddictBufferSize);
         }
 
-        DISPLAYLEVEL(3, "test%3i : check content size on duplicated context : ", testNb++);
-        {   size_t const testSize = CNBuffSize / 3;
-            CHECK_Z( ZSTD_compressBegin(ctxOrig, ZSTD_defaultCLevel()) );
-            CHECK_Z( ZSTD_copyCCtx(ctxDuplicated, ctxOrig, testSize) );
-
-            CHECK_VAR(cSize, ZSTD_compressEnd(ctxDuplicated, compressedBuffer, ZSTD_compressBound(testSize),
-                                          (const char*)CNBuffer + dictSize, testSize) );
-            {   ZSTD_FrameHeader zfh;
-                if (ZSTD_getFrameHeader(&zfh, compressedBuffer, cSize)) goto _output_error;
-                if ((zfh.frameContentSize != testSize) && (zfh.frameContentSize != 0)) goto _output_error;
-        }   }
-        DISPLAYLEVEL(3, "OK \n");
-
 #if !defined(ZSTD_EXCLUDE_DFAST_BLOCK_COMPRESSOR) \
  && !defined(ZSTD_EXCLUDE_GREEDY_BLOCK_COMPRESSOR) \
  && !defined(ZSTD_EXCLUDE_DFAST_BLOCK_COMPRESSOR) \
@@ -4416,7 +4369,6 @@ static int basicUnitTests(U32 const seed, double compressibility)
 #endif
 
         ZSTD_freeCCtx(ctxOrig);
-        ZSTD_freeCCtx(ctxDuplicated);
         ZSTD_freeDCtx(dctx);
     }
 
@@ -6882,7 +6834,6 @@ static int fuzzerTests(U32 seed, unsigned nbTests, unsigned startTest, U32 const
     BYTE* const cBuffer = (BYTE*) malloc (cBufferSize);
     BYTE* const dstBuffer = (BYTE*) malloc (dstBufferSize);
     BYTE* const mirrorBuffer = (BYTE*) malloc (dstBufferSize);
-    ZSTD_CCtx* const refCtx = ZSTD_createCCtx();
     ZSTD_CCtx* const ctx = ZSTD_createCCtx();
     ZSTD_DCtx* const dctx = ZSTD_createDCtx();
     U32 result = 0;
@@ -6899,7 +6850,7 @@ static int fuzzerTests(U32 seed, unsigned nbTests, unsigned startTest, U32 const
     cNoiseBuffer[3] = (BYTE*)malloc (srcBufferSize);
     cNoiseBuffer[4] = (BYTE*)malloc (srcBufferSize);
     CHECK (!cNoiseBuffer[0] || !cNoiseBuffer[1] || !cNoiseBuffer[2] || !cNoiseBuffer[3] || !cNoiseBuffer[4]
-           || !dstBuffer || !mirrorBuffer || !cBuffer || !refCtx || !ctx || !dctx,
+           || !dstBuffer || !mirrorBuffer || !cBuffer || !ctx || !dctx,
            "Not enough memory, fuzzer tests cancelled");
 
     /* Create initial samples */
@@ -7082,16 +7033,15 @@ static int fuzzerTests(U32 seed, unsigned nbTests, unsigned startTest, U32 const
                             testNb, (unsigned)maxTestSize, cLevel, (unsigned)dictSize);
 
             if (FUZ_rand(&lseed) & 0xF) {
-                CHECK_Z ( ZSTD_compressBegin_usingDict(refCtx, dict, dictSize, cLevel) );
+                CHECK_Z ( ZSTD_compressBegin_usingDict(ctx, dict, dictSize, cLevel) );
             } else {
                 ZSTD_compressionParameters const cPar = ZSTD_getCParams(cLevel, ZSTD_CONTENTSIZE_UNKNOWN, dictSize);
                 ZSTD_frameParameters const fPar = { FUZ_rand(&lseed)&1 /* contentSizeFlag */,
                                                     !(FUZ_rand(&lseed)&3) /* contentChecksumFlag*/,
                                                     0 /*NodictID*/ };   /* note : since dictionary is fake, dictIDflag has no impact */
                 ZSTD_parameters const p = FUZ_makeParams(cPar, fPar);
-                CHECK_Z ( ZSTD_compressBegin_advanced(refCtx, dict, dictSize, p, 0) );
+                CHECK_Z ( ZSTD_compressBegin_advanced(ctx, dict, dictSize, p, ZSTD_CONTENTSIZE_UNKNOWN) );
             }
-            CHECK_Z( ZSTD_copyCCtx(ctx, refCtx, 0) );
         }
 
         {   U32 const nbChunks = (FUZ_rand(&lseed) & 127) + 2;
@@ -7155,7 +7105,6 @@ static int fuzzerTests(U32 seed, unsigned nbTests, unsigned startTest, U32 const
     DISPLAY("\r%u fuzzer tests completed   \n", testNb-1);
 
 _cleanup:
-    ZSTD_freeCCtx(refCtx);
     ZSTD_freeCCtx(ctx);
     ZSTD_freeDCtx(dctx);
     free(cNoiseBuffer[0]);

From 9d728bb08622963fa95fbb0c788ac13162829916 Mon Sep 17 00:00:00 2001
From: Nick Terrell 
Date: Fri, 13 Nov 2026 11:52:54 -0800
Subject: [PATCH 91/94] changelog: note removal of ZSTD_copyCCtx() and
 ZSTD_copyDCtx()

---
 CHANGELOG | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG b/CHANGELOG
index c45657bbb59..42599054725 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,6 +4,7 @@ api: legacy format support is now disabled by default
 api: experimental `ZSTD_d_refMultipleDDicts` now requires an exact match for nonzero frame dictionary IDs in the registered set; missing nonzero IDs return `ZSTD_error_dictionary_wrong` instead of falling back to the current dictionary. Frames with dictionary ID 0 use a registered ID-0 DDict if present, or no dictionary otherwise.
 api: set-based multi-DDict decoding no longer remembers a selected dictionary; legacy frames require single-dictionary or explicit-dictionary decoding
 api: disabling experimental `ZSTD_d_refMultipleDDicts` releases every dictionary reference in its set; no selected dictionary is retained for subsequent decompression
+api: removed the deprecated experimental functions `ZSTD_copyCCtx()` and `ZSTD_copyDCtx()`
 build: `ZSTD_LEGACY_SUPPORT` defaults to `0` in Makefile and CMake
 
 V1.5.7 (Feb 2025)

From 0d8d37ff0ea48a87e3ba51a83a14c7235485adad Mon Sep 17 00:00:00 2001
From: Yann Collet 
Date: Fri, 13 Nov 2026 16:06:17 -0800
Subject: [PATCH 92/94] ci: run qemu-consistency on ubuntu-latest

qemu-consistency was pinned to ubuntu-24.04 in 2025, to get a newer GCC
than ubuntu-22.04 for the RISC-V vector tests. It now runs on
ubuntu-latest:
- qemu-user replaces qemu-user-static, which is only a virtual package on
  ubuntu-26.04, and the emulators lose their -static suffix;
- qemu-user-binfmt keeps the binfmt registration that qemu-user-static
  provided, because the tests run the cross-compiled datagen directly. On
  ubuntu-26.04, its handlers are only loaded when systemd-binfmt
  restarts, so the job restarts it and checks the handler of its target;
- the qemu-system-* emulators and qemu-utils are no longer installed,
  since the job only uses user-mode emulation;
- RISC-V uses the default cross compiler (GCC 15 on ubuntu-26.04) instead
  of gcc-14. Its libc targets the RVA23 profile, which the generic rv64
  CPU model of qemu lacks, so the vector tests use -cpu max with each
  vlen;
- ubuntu-26.04 has no MIPS cross compiler, so MIPS builds and runs its
  tests in a debian:13 container (GCC 14.2). The binfmt handlers of the
  runner use the F flag, so they also work in the container.
---
 .github/workflows/dev-short-tests.yml | 41 ++++++++++++++++-----------
 1 file changed, 25 insertions(+), 16 deletions(-)

diff --git a/.github/workflows/dev-short-tests.yml b/.github/workflows/dev-short-tests.yml
index f09df871978..b22534d7df0 100644
--- a/.github/workflows/dev-short-tests.yml
+++ b/.github/workflows/dev-short-tests.yml
@@ -401,20 +401,21 @@ jobs:
 
   qemu-consistency:
     name: QEMU ${{ matrix.name }}
-    runs-on: ubuntu-24.04
+    runs-on: ubuntu-latest
     strategy:
       fail-fast: false  # 'false' means Don't stop matrix workflows even if some matrix failed.
       matrix:
         include: [
-          { name: ARM,      xcc_pkg: gcc-arm-linux-gnueabi,     xcc: arm-linux-gnueabi-gcc,     xemu_pkg: qemu-system-arm,    xemu: qemu-arm-static     },
-          { name: ARM64,    xcc_pkg: gcc-aarch64-linux-gnu,     xcc: aarch64-linux-gnu-gcc,     xemu_pkg: qemu-system-aarch64,xemu: qemu-aarch64-static },
-          { name: PPC,      xcc_pkg: gcc-powerpc-linux-gnu,     xcc: powerpc-linux-gnu-gcc,     xemu_pkg: qemu-system-ppc,    xemu: qemu-ppc-static     },
-          { name: PPC64LE,  xcc_pkg: gcc-powerpc64le-linux-gnu, xcc: powerpc64le-linux-gnu-gcc, xemu_pkg: qemu-system-ppc,    xemu: qemu-ppc64le-static },
-          { name: S390X,    xcc_pkg: gcc-s390x-linux-gnu,       xcc: s390x-linux-gnu-gcc,       xemu_pkg: qemu-system-s390x,  xemu: qemu-s390x-static   },
-          { name: MIPS,     xcc_pkg: gcc-mips-linux-gnu,        xcc: mips-linux-gnu-gcc,        xemu_pkg: qemu-system-mips,   xemu: qemu-mips-static    },
-          { name: RISC-V,   xcc_pkg: gcc-14-riscv64-linux-gnu,  xcc: riscv64-linux-gnu-gcc-14,  xemu_pkg: qemu-system-riscv64,xemu: qemu-riscv64-static },
-          { name: M68K,     xcc_pkg: gcc-m68k-linux-gnu,        xcc: m68k-linux-gnu-gcc,        xemu_pkg: qemu-system-m68k,   xemu: qemu-m68k-static    },
-          { name: SPARC,    xcc_pkg: gcc-sparc64-linux-gnu,     xcc: sparc64-linux-gnu-gcc,     xemu_pkg: qemu-system-sparc,  xemu: qemu-sparc64-static },
+          { name: ARM,      xcc_pkg: gcc-arm-linux-gnueabi,     xcc: arm-linux-gnueabi-gcc,     xemu: qemu-arm     },
+          { name: ARM64,    xcc_pkg: gcc-aarch64-linux-gnu,     xcc: aarch64-linux-gnu-gcc,     xemu: qemu-aarch64 },
+          { name: PPC,      xcc_pkg: gcc-powerpc-linux-gnu,     xcc: powerpc-linux-gnu-gcc,     xemu: qemu-ppc     },
+          { name: PPC64LE,  xcc_pkg: gcc-powerpc64le-linux-gnu, xcc: powerpc64le-linux-gnu-gcc, xemu: qemu-ppc64le },
+          { name: S390X,    xcc_pkg: gcc-s390x-linux-gnu,       xcc: s390x-linux-gnu-gcc,       xemu: qemu-s390x   },
+          # ubuntu-26.04 has no MIPS cross compiler: MIPS builds in a Debian container
+          { name: MIPS,     xcc_pkg: "",                        xcc: mips-linux-gnu-gcc,        xemu: qemu-mips    },
+          { name: RISC-V,   xcc_pkg: gcc-riscv64-linux-gnu,     xcc: riscv64-linux-gnu-gcc,     xemu: qemu-riscv64 },
+          { name: M68K,     xcc_pkg: gcc-m68k-linux-gnu,        xcc: m68k-linux-gnu-gcc,        xemu: qemu-m68k    },
+          { name: SPARC,    xcc_pkg: gcc-sparc64-linux-gnu,     xcc: sparc64-linux-gnu-gcc,     xemu: qemu-sparc64 },
         ]
     env:                        # Set environment variables
       XCC: ${{ matrix.xcc }}
@@ -424,9 +425,14 @@ jobs:
     - name: apt update & install
       run: |
         sudo apt-get update
-        sudo apt-get install gcc-multilib g++-multilib qemu-utils qemu-user-static
-        sudo apt-get install ${{ matrix.xcc_pkg }} ${{ matrix.xemu_pkg }}
+        # qemu-user-binfmt: the tests run the cross-compiled datagen directly
+        sudo apt-get install gcc-multilib g++-multilib qemu-user qemu-user-binfmt
+        sudo apt-get install ${{ matrix.xcc_pkg }}
+        # load the binfmt handlers of qemu-user-binfmt, and check the one of this target
+        sudo systemctl restart systemd-binfmt
+        cat /proc/sys/fs/binfmt_misc/$XEMU
     - name: Environment info
+      if: ${{ matrix.name != 'MIPS' }}
       run: |
         echo && which $XCC
         echo && $XCC --version
@@ -464,14 +470,17 @@ jobs:
     - name: MIPS
       if: ${{ matrix.name == 'MIPS' }}
       run: |
-        LDFLAGS="-static" CC=$XCC QEMU_SYS=$XEMU make clean check
+        docker run --rm -v "$PWD":/zstd -w /zstd -e XCC -e XEMU debian:13 sh -c '
+          apt-get -qq update && apt-get -qq install -y file make gcc-mips-linux-gnu qemu-user >/dev/null &&
+          $XCC --version && $XEMU --version &&
+          LDFLAGS="-static" CC=$XCC QEMU_SYS=$XEMU make clean check'
     - name: RISC-V
       if: ${{ matrix.name == 'RISC-V' }}
       run: |
         LDFLAGS="-static" CC=$XCC QEMU_SYS=$XEMU make clean check
-        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu rv64,v=true,vlen=128" make clean check
-        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu rv64,v=true,vlen=256" make clean check
-        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu rv64,v=true,vlen=512" make clean check
+        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu max,vlen=128" make clean check
+        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu max,vlen=256" make clean check
+        CFLAGS="-march=rv64gcv -O3" LDFLAGS="-static -DMEM_FORCE_MEMORY_ACCESS=0" CC=$XCC QEMU_SYS="$XEMU -cpu max,vlen=512" make clean check
     - name: M68K
       if: ${{ matrix.name == 'M68K' }}
       run: |

From 9776f6d64117b9d857d6e7ad5974b33fe770b0fc Mon Sep 17 00:00:00 2001
From: Jay Howard 
Date: Sun, 6 Sep 2026 20:36:43 -0500
Subject: [PATCH 93/94] programs: strip trailing '\r' from --filelist entries
 on Windows

`--filelist` stopped accepting Windows CRLF line endings in 165e52ce
("first implementation supporting Process Substitution", #4349). That
rewrite reads the list into a buffer opened in binary mode and splits it
on '\n' alone. Previously the list was opened in text mode, where the
Windows CRT translated CRLF to LF before the parser saw it, so stripping
'\n' was sufficient. In binary mode the CR survives into every path:

    zstd: can't stat a.txt : No such file or directory -- ignored

The '\r' is invisible in that message, which makes it awkward to
diagnose.

Strip a trailing '\r' when the line pointers are built, but only on
Windows, where CRLF is the native line separator and a '\r' cannot be
used in a filename. Elsewhere '\r' is a legal filename byte and is left
alone, so behaviour on those platforms is unchanged. This restores the
pre-regression behaviour exactly, on every platform, without adding a
new one.

Adds a playTests case, guarded to Windows, that fails without the fix.
---
 programs/util.c    |  7 +++++++
 tests/playTests.sh | 17 +++++++++++++++++
 2 files changed, 24 insertions(+)

diff --git a/programs/util.c b/programs/util.c
index edd858ba723..f9fce9f5b63 100644
--- a/programs/util.c
+++ b/programs/util.c
@@ -759,6 +759,13 @@ static const char** UTIL_createLinePointers(char* buffer, size_t numLines, size_
             len++;
         }
 
+#if defined(_WIN32)
+        /* note: read in binary mode, so the CRT no longer folds CRLF; CR is legal in a filename elsewhere */
+        if (len > 0 && buffer[pos + len - 1] == '\r') {
+            buffer[pos + len - 1] = '\0';
+        }
+#endif
+
         /* Move past this string and its null terminator */
         pos += len;
         if (pos < bufferSize) pos++;  /* Skip the null terminator if we're not at buffer end */
diff --git a/tests/playTests.sh b/tests/playTests.sh
index a9054ae90d5..1c8ab0c4999 100755
--- a/tests/playTests.sh
+++ b/tests/playTests.sh
@@ -828,6 +828,23 @@ zstd -f --filelist=tmp_symLink
 test -f tmp2.zst
 test -f tmp1.zst
 
+# CR is only stripped when _WIN32 is defined: MSVC and MinGW, but not Cygwin
+stripsCR=false
+if [ "$isWindows" = true ] ; then
+    case "$UNAME" in
+      MINGW*|MSYS*) stripsCR=true ;;
+    esac
+fi
+
+if [ "$stripsCR" = true ] ; then
+    println "test : file list with Windows CRLF line endings, --filelist=FILE" # (#4349)
+    rm -f *.zst
+    printf 'tmp1\r\ntmp2\r\n' > tmp_crlfList
+    zstd -f --filelist=tmp_crlfList
+    test -f tmp1.zst
+    test -f tmp2.zst
+fi
+
 println "test : compress multiple files reading them from multiple files, --filelist=FILE"
 rm -f *.zst
 println "Hello world!, file3" > tmp3

From 694fdb8cf6eb5229aba1c9412f17fda1289ee965 Mon Sep 17 00:00:00 2001
From: Yann Collet 
Date: Sat, 14 Nov 2026 21:53:33 -0800
Subject: [PATCH 94/94] tests: check the --filelist CR handling on every
 platform

The CRLF test only ran when the shell was MINGW* or MSYS*. Native Windows
builds are now detected once, as Windows minus Cygwin (the only Windows
environment that doesn't define _WIN32), so new MSYS2 environment names
don't silently skip the test.

The test also checks the other half of the behavior: elsewhere, a file
name ending with CR is kept, so the CR strip stays limited to _WIN32.
---
 tests/playTests.sh | 37 ++++++++++++++++++++++++-------------
 1 file changed, 24 insertions(+), 13 deletions(-)

diff --git a/tests/playTests.sh b/tests/playTests.sh
index 1c8ab0c4999..3ce5849b703 100755
--- a/tests/playTests.sh
+++ b/tests/playTests.sh
@@ -112,6 +112,15 @@ case "$OS" in
     ;;
 esac
 
+# Native Windows builds (MSVC, MinGW) define _WIN32; Cygwin builds don't
+isNativeWindows=false
+if [ "$isWindows" = true ]; then
+  case "$UNAME" in
+    CYGWIN*) ;;
+    *) isNativeWindows=true ;;
+  esac
+fi
+
 case "$UNAME" in
   Darwin) MD5SUM="md5 -r" ;;
   NetBSD) MD5SUM="md5 -n" ;;
@@ -828,22 +837,24 @@ zstd -f --filelist=tmp_symLink
 test -f tmp2.zst
 test -f tmp1.zst
 
-# CR is only stripped when _WIN32 is defined: MSVC and MinGW, but not Cygwin
-stripsCR=false
-if [ "$isWindows" = true ] ; then
-    case "$UNAME" in
-      MINGW*|MSYS*) stripsCR=true ;;
-    esac
-fi
-
-if [ "$stripsCR" = true ] ; then
-    println "test : file list with Windows CRLF line endings, --filelist=FILE" # (#4349)
-    rm -f *.zst
-    printf 'tmp1\r\ntmp2\r\n' > tmp_crlfList
-    zstd -f --filelist=tmp_crlfList
+println "test : file list with CR at the end of lines, --filelist=FILE"
+# The list is read in binary mode. Native Windows builds drop the CR of CRLF line endings;
+# other builds keep it, since CR is a valid filename character there.
+rm -f *.zst
+if [ "$isNativeWindows" = true ]; then
+    printf 'tmp1\r\ntmp2\r\n' > tmp_crList
+    zstd -f --filelist=tmp_crList
     test -f tmp1.zst
     test -f tmp2.zst
+else
+    CR=$(printf '\r')
+    echo "CR" > "tmpCR$CR"
+    printf 'tmpCR\r\n' > tmp_crList
+    zstd -f --filelist=tmp_crList
+    test -f "tmpCR$CR.zst"
+    rm -f "tmpCR$CR" "tmpCR$CR.zst"
 fi
+rm -f tmp_crList
 
 println "test : compress multiple files reading them from multiple files, --filelist=FILE"
 rm -f *.zst