Repository navigation
Add StreamingDecompress#frame_complete? to detect truncated input - #165
Open
reidmorrison wants to merge 1 commit into
Open
reidmorrison wants to merge 1 commit into
reidmorrison wants to merge 1 commit into
Conversation
StreamingDecompress#decompress and #decompress_with_pos discard the return value of ZSTD_decompressStream: 0 once a frame is decoded and flushed, and otherwise how much more input the frame needs. Without it, a caller cannot tell input that ends at the end of a frame from input that was cut short. Both return the data decoded so far without an error, and input cut right after a block, or before the content checksum, returns all of the data it holds. Record after each call whether the input consumed so far ends at the end of a frame, and expose it as frame_complete?. Only a call that consumes input or produces output updates it: after a complete frame, libzstd answers a call with no input with the size of the next frame header, which says nothing about the input so far. It is true before any input, and false after a call raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
Zstd::StreamingDecompress#frame_complete?. It returnstruewhen the input decompressed so far ends exactly at the end of a frame, andfalsewhen it ends within a frame, so once all of the input has been passed in,falsemeans that it was cut short:Motivation
IOStreams is a Ruby library that streams files through compression, encryption and storage (local files, S3, SFTP) one block at a time. On MRI it reads
.zstfiles withZstd::StreamingDecompress#decompress_with_pos, and it cannot detect a truncated file, for example one cut short by an interrupted transfer or a full disk.decompressanddecompress_with_posreturn the data decoded so far without an error, whether the input ends at the end of a frame or within one. When it ends right after a block (a writer that calledflushand then stopped) or just before the content checksum, they even return all of the data the file holds. Thezstdcommand line tool reports these files as truncated ("premature end"), and zstd-jni, which IOStreams uses on JRuby, raisesTruncated source.ZSTD_decompressStreamreturns exactly this: 0 once a frame is decoded and flushed, and otherwise how much more input the frame needs. Both methods discard it. #88 returned it fromdecompress2, but it was dropped when that becamedecompress_with_pos, before 2.0.0.Change
After each call to
ZSTD_decompressStreamindecompressanddecompress_with_pos,StreamingDecompressrecords whether the input consumed so far ends at the end of a frame, andframe_complete?returns it.Returning the raw value would not be enough: after a complete frame, a call with no input returns 9 (the size of the next frame header and block header), although nothing is missing. So only a call that makes progress, by consuming input or producing output (libzstd's own definition of forward progress), updates it:
truefor a new stream, before any input.falsewhile the input ends within a frame: in the magic number, the frame header, a block header, a block, or the checksum, or between two blocks.trueagain whenever a frame ends, for any number of frames, including skippable and empty frames.falseafter a call raises.With
decompress_with_pos, input that the call did not consume counts once it is passed in again. For example, when the output of a frame needs more than one call, libzstd holds back the last byte of the frame until the rest of its output has been returned, andframe_complete?staysfalseuntil then.Compatibility
This only adds a method.
decompressanddecompress_with_posreturn the same values as before.Tests
spec/zstd-ruby-streaming-decompress_spec.rbuses a frame written by thezstdcommand line tool, which has a content checksum, and checks thatframe_complete?:truebefore any input, and after empty input;truewhen the input ends at the end of a frame;falsewhen the input ends within the magic number, the frame header, a block header, a block, right before the checksum, or within the checksum, andtrueonce the rest arrives;falsewhen the input ends between two blocks, afterStreamingCompress#flush, although all of its data has been returned;trueonly at the end of each of several frames, including a skippable and an empty frame, fed one byte at a time to bothdecompressanddecompress_with_pos;falsewhen the input ends within a later frame;trueover repeated calls with empty input after a complete frame;falseuntildecompress_with_poshas returned all the output of a 1 MB frame;falseafter the input fails to decompress.bundle exec rakepasses with 105 examples, 0 failures (91 onmain), on Ruby 3.2, 3.4 and 4.0 on macOS (arm64), with no new compiler warnings.I also checked it against 39 files written by the
zstdcommand line tool (from files and from stdin, with and without checksums,-19,--fast=5,--long=24,-T4,--stream-size) and by this gem (one shot and streaming, levels -5 to 19). They hold empty, small, text, random, all-zero and mixed data, plus a skippable frame on its own and 8 concatenated frames including 2 skippable frames and an empty frame. Each file was cut at many points (every byte for small files, and every byte near each frame boundary for large ones) and read withdecompressanddecompress_with_pos, in 64 KiB chunks and one byte at a time: 12,816 reads, all correct, apart from 205 reads of the concatenated frames withdecompressin 64 KiB chunks, which hit the bug below.Related
Since v2.0.1,
StreamingDecompress#decompressstops when a frame without output (an empty or skippable frame) ends, and drops the rest of its input, so for such inputframe_complete?can only describe whatdecompressdecoded. #166 fixesdecompress. The two PRs are independent and merge cleanly in either order; with both, all 12,816 reads are correct.Zstd::StreamReadercould useframe_complete?to raise on a truncated stream instead of returning short data. I left that out, since it changes how an experimental class behaves, and can send it separately.🤖 Generated with Claude Code