Skip to content

igzip: fix x86 inflate counts when the main loop finds an error - #446

Open
ptweezy wants to merge 3 commits into
intel:masterfrom
ptweezy:igzip-x86-inflate-error-counts
Open

ptweezy wants to merge 3 commits into
intel:masterfrom
ptweezy:igzip-x86-inflate-error-counts

Conversation

@ptweezy

@ptweezy ptweezy commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

When the main loop of the x86 inflate decoders (decode_huffman_code_block_stateless_01 and _04, built from igzip/igzip_decode_block_stateless.asm) finds an invalid distance symbol, a distance before the start of the output, or an invalid lit/len symbol, it leaves wrong counts in inflate_state. In stateful inflate, it can also hand stale bytes to the caller.

The bugs

  1. The main loop moves next_out past the whole copy of a length/distance pair before it decodes and checks the distance, and both distance errors return from there. total_out then counts up to 258 bytes that the copy never wrote, and avail_out is short by as much.
    • isal_inflate_stateless() reports those bytes of the output buffer as output.
    • isal_inflate() copies them to the caller with the error. For the first 64 KB of a stream it decodes into tmp_out_buffer, so apart from the first few bytes, the caller gets whatever that buffer held: earlier output of the same stream, output of an earlier stream that used the same inflate_state, or uninitialized memory (isal_inflate_init() and isal_inflate_reset() do not clear it). Later in a stream, the caller gets the old contents of its own buffer.
  2. The main loop stops 8 bytes before the end of the input and 274 before the end of the output by moving end_in and end_out back, and its three error exits do not restore them. avail_in comes out 8 bytes short and avail_out 274 bytes short on top of bug 1. Both can wrap to about 4 GB, and a caller that calls isal_inflate() again after the error then reads past the end of its input.

The exits outside the main loop are correct. The C and aarch64 decoders check the distance before moving next_out and track the real buffer ends, so they are not affected. igzip_rand_test misses these bugs because it checks the inflate state only when inflate succeeds.

Changes

  1. Compute avail_in and avail_out at the end of the function from the buffer ends in inflate_state, which the function does not change. The exits that already restored end_in and end_out give master's results.
  2. On the main loop's two distance errors, move next_out back by the length of the copy.
  3. Add a test to igzip_rand_test that inflates a block of literals followed by an invalid lit/len symbol, a length with an invalid distance symbol, or a distance before the start of the output, with the error near and far from the buffer ends, stateless and stateful. It checks the return code, total_out, the output, and that next_in + avail_in and next_out + avail_out are the ends of the buffers.

Testing

On Linux x86-64 with an AVX2 CPU, where make check runs _04:

  • At each commit, make -f Makefile.unx check passes for x86-64 and arch=noarch, and tools/check_format.sh passes.
  • Without the fixes, the new test fails. For example, when the first symbol of a stateless inflate is a length with an invalid distance symbol, with 16 bytes of input after the block and 300 bytes of output space, inflate returns total_out 3 and avail_out 23 instead of 0 and 300. With only the first fix, the distance cases still fail. The test passes on master with arch=noarch, and on this PR with _01 dispatched.
  • A differential test inflated 100,000 valid and corrupted streams (stateless, and stateful with random buffer sizes) with master's and this PR's x86-64 builds and with the C decoder (arch=noarch). This PR matches the C decoder's return code, total_out, output and stateless avail_out in every case; master's total_out differs in 2,035. avail_in can differ by a few bytes because the decoders buffer different amounts of input, but next_in + avail_in is the end of the input in both, except for stateless inflates whose input ends inside a dynamic block header (a separate bug in the C code of isal_inflate_stateless()).

On Windows 11 (Ryzen 7 9800X3D), the mingw-w64 x86-64 build passes make check natively, including the new test, with _04 and with _01 dispatched.

On aarch64, make check passes on macOS (Apple M4; each commit with arch=aarch64, the tip with arch=noarch) and at the tip on Linux in Docker (GCC 14.2, Makefile.unx and autotools), and the x86 commits leave every aarch64 library object unchanged. Added to master alone, the new test passes with the aarch64 and C decoders, and fails with a copy of the C decoder changed to count a copy whose distance check fails, as the x86 decoder does. In 100,000 inflates on the M4 (stateless and stateful, about a third corrupted), the aarch64 and C decoders give the same return code, total_out, output, avail_out, avail_in and next_in.

decode_huffman_code_block_stateless_01 and _04 decode most of a block
in a main loop that stops IN_BUFFER_SLOP (8) bytes before the end of
the input and OUT_BUFFER_SLOP (274) bytes before the end of the output,
by moving end_in and end_out back. The exits from the main loop at the
end of a block or near the ends of the buffers move them forward again,
but the exits for an invalid lit/len symbol, an invalid distance
symbol, and an invalid look-back distance do not. avail_in then comes
out 8 bytes short and avail_out 274 bytes short, so next_in + avail_in
and next_out + avail_out no longer reach the ends of the buffers. The
main loop can load input up to 6 bytes past its end_in, so avail_in
can also wrap to about 4 GB.

Compute avail_in and avail_out from the buffer ends given in the
inflate_state, which the function does not change before it returns.
This gives the same results for the exits that already restored end_in
and end_out.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
In the main loop of decode_huffman_code_block_stateless_01 and _04, a
length symbol moves next_out past the whole copy before the distance is
decoded and checked. If the distance symbol is invalid, or the distance
reaches back before the start of the output, the function returns
ISAL_INVALID_SYMBOL or ISAL_INVALID_LOOKBACK with next_out still past
the copy. total_out then counts up to 258 bytes that the copy never
wrote, and avail_out is short by as much.

isal_inflate_stateless() reports those bytes of the output buffer as
output. isal_inflate() also copies them to the caller when it decodes
into tmp_out_buffer. The first of them come from the decoder itself:
its 8-byte store of the length symbol, and copies that wrote past their
end. The rest are whatever the buffer held before: earlier output of
the same stream, data of an earlier stream inflated with the same
inflate_state, or memory that was never initialized, since
isal_inflate_init() and isal_inflate_reset() do not clear it.

Move next_out back by the length of the copy on these two exits. The
exits outside the main loop check the distance before they move
next_out, and do not change.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
Add a test to igzip_rand_test that inflates a block of literals
followed by an invalid lit/len symbol, or by a length with an invalid
distance symbol or with a distance before the start of the output. The
errors come near and far from the ends of the input and output
buffers, and inflate runs both stateless and stateful. The test checks
that inflate returns the error with exactly the literals as output, and
that next_in + avail_in and next_out + avail_out are the ends of the
buffers.

Without the two previous commits, the test fails on x86 when the error
is far from the ends of the buffers.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
ptweezy added a commit to ptweezy/isa-l that referenced this pull request Oct 21, 2026
The main loop of the x86 inflate decoder stops OUT_BUFFER_SLOP (274)
bytes before the end of the output buffer and leaves the rest to
end_loop_block, which decodes a symbol at a time and copies matches
with rep movsb. When the output buffer is the size of the output, that
is a quarter of a 1 KB stream.

Within OUT_BUFFER_SLOP of the end, decode the next lookup in the main
loop if its output fits: 8 bytes for literals, and 1 + the length +
2 * COPY_SIZE bytes for a length, which covers the literal before it
and the stores of both copy loops. Only the last bytes, and codes longer
than the lookup, go to end_loop_block.

Errors in the last 274 bytes of the output then take the main loop's
error exits, whose total_out, avail_in and avail_out counts intel#446 fixes.
Errors further from the end already take them, and avail_out wraps
around in a few of those cases; near the end it wraps in most.

Together with the next commit, inflate of 1 KB zlib -6 streams into an
output buffer of their size gets 11% to 15% faster on GitHub-hosted x86
runners, and of 4 KB streams 4% to 7% faster.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant