st_decompress() decodes only the first frame of a concatenated lz5/lizard stream

Author: DraftingDreamerCreated Aug 4, 2026Updated Aug 4, 2026

st_decompress() — the path taken for a raw (non-container) .lz5 / .liz stream — drives its outer loop from the frame's own "bytes still wanted" value:

c
// C/zstdmt/lz5-mt_decompress.c:432
for (; nextToLoad; pos = 0) {

so it exits as soon as one frame ends, even when the reader still has bytes to give. The function then falls through to return 0: 7z t says the archive is fine and 7z x writes a silently truncated file.

lz4-mt_decompress.c had the identical shape until 71a0a67a ("Fix extracting multiple LZ4 frames", 2020-12-13), which rewrote the loop to run until the reader reports EOF. lz5 and lizard were never updated — not here, and not in mcmilk/zstdmt either, where the same fix landed the same day as 939dcd0c, also lz4-only. The three st_decompress() bodies were identical apart from type names when they were introduced (58069903 for lz4/lz5, 294d7a00 for lizard), so this looks like the port simply never happened.

Measured against a test that decodes three differently-sized frames and their concatenations:

lz4 (master) lz5 (master) lizard (master)
frame A alone ok ok ok
A+B ok 3145728 of 8388608 3145728 of 8388608
A+B+C, mixed blockSizeID ok 3145728 of 9437184 3145728 of 9437184
B+A (reversed) ok 5242880 of 8388608 5242880 of 8388608
A+empty+C ok 3145728 of 4194304 3145728 of 4194304

Every one of those returns 0 — no error is reported.

How reachable is it

This project's own encoder always wraps its output in the MAGIC_SKIPPABLE container (lz5-mt_compress.c has no threads != 1 branch, unlike lz4), so a .lz5 / .liz produced here never takes this path. Reaching it needs a raw stream from somewhere else — a foreign LZ5F/LizardF encoder, or a payload lifted out of a container. LZ5MT_decompressDCtx() dispatches on the file's magic (lz5-mt_decompress.c:515-522), so such a file is accepted and decoded; it just stops early.

PR follows.