Skip to content

igzip: fix full flush corruption and two other bugs - #439

Open
ptweezy wants to merge 3 commits into
intel:masterfrom
ptweezy:igzip-stateful-fixes
Open

ptweezy wants to merge 3 commits into
intel:masterfrom
ptweezy:igzip-stateful-fixes

Conversation

@ptweezy

@ptweezy ptweezy commented Oct 18, 2026 •

Copy link
Copy Markdown

This PR fixes three igzip bugs, one per commit.

Reset the match history when a full flush is pending

Stateful FULL_FLUSH compression can read memory before the caller's input buffer and produce a valid deflate stream that inflates to the wrong data, at every level and on every architecture. This happens when the output buffer fills before the end of the block is written and the caller supplies more input before the flush completes.

When a call compresses all of its input into a compressed block under FULL_FLUSH, get_hist_size() drops the history, but the match history is reset only when sync_flush() writes the sync marker. The next call compresses its new input before writing the marker, with the old hash table but without the history it refers to, so match candidates point up to 32 KB before the new input buffer.

To reproduce, call isal_deflate() with FULL_FLUSH and 1 to 91 bytes of output space per call, add 1 to 117 bytes of input whenever avail_in reaches 0, and check the result with zlib. On the first 512 KB of dickens, master faults at levels 0 to 3 when each input chunk follows a PROT_NONE page. When each chunk follows unrelated text, master's output is wrong in 1 to 4 of 20 runs at level 0 and in all 20 at levels 1 to 3, although zlib accepts every stream as valid. With this fix, no run faults or gives wrong output.

If the call that leaves the flush pending compresses from the internal buffer instead, the history there (earlier NO_FLUSH or SYNC_FLUSH input, or a dictionary) stays. When the next call's input does not fit next to it, isal_deflate() compresses with flush set to NO_FLUSH, so sync_flush() writes the pending marker without resetting has_hist, and the input after the marker is compressed against the data before it. The stream inflates correctly, but igzip_lib.h documents that FULL_FLUSH makes the following blocks independent. A test that leaves the marker pending after each chunk's first call (where the output size allows it) and then supplies 1 byte to 64 KB of new input (dickens, 400 streams per level) finds data that refers back across 293 of master's 2,620 sync markers. It also hits the first case: 3 of master's level 3 streams inflate to the wrong data.

The fix sets has_hist to IGZIP_NO_HIST when a call compresses all input into a compressed block while a full flush is pending, so the next call resets the match history before compressing new input. In the same test, the data after each of the 2,666 sync markers decompresses on its own. Output is byte-identical to master's for callers that keep the flush type until each flush completes and supply more input only after it does, as the existing igzip_rand_test cases do.

The commit adds two tests to igzip_rand_test. test_full_flush_pending compresses at each level with FULL_FLUSH and small output buffers, and supplies more input while a flush is pending. test_full_flush_pending_marker leaves the sync marker pending at the start of a stream, after a SYNC_FLUSH and after a dictionary, supplies 40 KB more input, and checks that the data after the marker decompresses on its own.

Store the ICF buffer space in bytes

icf_buf_avail_out holds bytes everywhere except in update_state() (igzip_icf_base.c) and the aarch64 level 1 and 2 body and finish functions, which store an entry count. Each call that leaves the block open cuts the remaining space to a quarter, so blocks end early. Stateless level 1 and 2 compression is affected too: the finish function runs right after the body and sees a quarter of the space the body left, which with ISAL_DEF_LVL1_MIN or ISAL_DEF_LVL2_MIN level buffers is room for at most 217 ICF entries, so it can close the block early and put the rest of the input in another block. The x86 assembly already stores bytes, so x86 output does not change.

With the fix, stateful level 1 and 2 compression of dickens in 4 KB, 16 KB and 64 KB input buffers gives the same size as a single call. On master, aarch64 level 1 with 4 KB buffers gives 45.11% instead of 45.01%; arch=noarch is affected too.

Label igzip_perf inflate results by inflate mode

igzip_perf labels the stateless, stateful and zlib inflate runs with the deflate mode, so all three print the same name.

Conflicts

This PR conflicts with #448 in igzip/igzip_rand_test.c.

Testing

On x86-64 Linux (Ubuntu 24.04, GCC 13.3) and aarch64 Linux under qemu-user 8.2 (GCC 13.3):

  • At every commit, tools/check_format.sh passes, and make -f Makefile.unx check passes on x86-64, with arch=noarch, and on aarch64.
  • igzip_rand_test passes with seeds 1 to 20 on x86-64 and 1 to 10 on aarch64.
  • Without the first commit, on x86-64, test_full_flush_pending fails with the default seed and 16 of seeds 1 to 20 (its buffer sizes come from rand(), so the failing seeds depend on the platform), and test_full_flush_pending_marker fails after the SYNC_FLUSH or the dictionary with the default seed and 19 of seeds 1 to 20.
  • A differential test that supplies new input only after each flush completes (levels 0 to 3, stateless and stateful, all flush types, gzip and zlib wrappers, hist_bits, dictionaries, random buffer sizes, every input buffer ending at a PROT_NONE page) matches master in all 30,000 x86-64 configurations. On aarch64, all 3,773 configurations at level 0 and 3,766 at level 3 match; at levels 1 and 2, the second fix changes 976 of 3,762 and 1,057 of 3,699 streams. zlib inflates all 7,775 finished raw deflate, gzip and zlib streams without a preset dictionary.

When a call compresses all input from the caller's buffer and the flush
type is FULL_FLUSH, get_hist_size() returns 0, so isal_deflate() keeps
no history. The match history is reset when sync_flush() writes the sync
marker: it sets has_hist to IGZIP_NO_HIST, and the next call resets the
hash table.

If the output buffer fills before the end of the block is written, the
flush is still pending when isal_deflate() returns, and has_hist is
not IGZIP_NO_HIST. If the caller supplies more input at that point,
the next call compresses that input before it writes the sync marker,
with the old hash table but without the history it refers to. At every
level, match candidates can then point up to 32 KB before the start of
the caller's input buffer, so the compressor reads memory outside the
input. When those bytes happen to match, it emits a valid deflate
stream that inflates to different data.

When the call that leaves the flush pending compresses from the internal
buffer, which holds the history of earlier input compressed with
NO_FLUSH or SYNC_FLUSH, or of a dictionary, the history stays there. If
the next call's input does not fit next to it, isal_deflate() clears the
flush type while it compresses, so it writes a pending sync marker
without resetting the match history, and the input after the marker is
compressed against the data before it. A full flush is meant to make the
following blocks independent of earlier data.

When a call compresses all input while a full flush is pending, set
has_hist to IGZIP_NO_HIST, so that the next call resets the match
history before it compresses new input, as it does after the sync
marker. Stored blocks are left alone: write_stored_block() resets the
match history when it completes a full flush. Callers that keep the
flush type until each flush completes, and supply more input only
after it completes, get the same output as before.

Add two tests to igzip_rand_test. One compresses with FULL_FLUSH and
small output buffers at each level, and supplies more input while a
flush is pending; without the fix, it fails for most seeds, including
the default one. The other leaves the sync marker of a full flush
pending at the start of a stream, after a SYNC_FLUSH, and after a
dictionary, then supplies IGZIP_HIST_SIZE + 8 KB more input, and
checks that the data after the marker decompresses on its own. Without
the fix, it fails after the SYNC_FLUSH or the dictionary for most
seeds.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
level_buf->icf_buf_avail_out holds the free space in the ICF buffer in
bytes. init_new_icf_block() and compress_icf_map_g() store bytes, every
reader treats the value as bytes, and the x86 level 1 and 2 functions
subtract the bytes they write. update_state() in igzip_icf_base.c and
the aarch64 level 1 and 2 body and finish functions store a count of
deflate_icf entries instead, so every call that leaves the block open
cuts the remaining space to a quarter.

The block then ends early. In stateless compression this happens when
the finish function runs after the body. In stateful compression it
happens each time a call runs out of input, so smaller input buffers
give more blocks. On dickens at level 1, 4 KB input buffers give
45.11% instead of the 45.01% of a single call.

Store the space in bytes. Stateful level 1 and 2 compression of
dickens in 4 KB, 16 KB, or 64 KB input buffers then gives the same
size as a single call.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
print_perf_line() picks the label from the deflate strategy, so the
isal stateless, isal stateful, and zlib inflate runs all print the
label of the deflate run before them, for example zlib_inflate for
every inflate run after a zlib level. Use info->inflate_mode, which
the inflate runs already set.

Signed-off-by: Parker Loflin <parker@cronstable.dev>
@ptweezy
ptweezy force-pushed the igzip-stateful-fixes branch from 3a36e65 to c5d4955 Compare October 24, 2026 01:49
@ptweezy ptweezy changed the title igzip: fix full flush corruption and other deflate bugs igzip: fix full flush corruption and two other bugs Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants