FlateDecode: a stored first block begins with NUL, which is white-space - #23
Merged
Conversation
flateDecode skipped leading white-space before inflating, because producers put the stream's EOL there. NUL is one of the six bytes the specification calls white-space, and a bare deflate stream whose first block is STORED begins with a NUL. Skipping it ate a real byte. The result was not an error. 00 03 00 fc ff "abc", once its NUL is gone, reads as a fixed-Huffman final block that ends at once: the stream inflated to NOTHING and reported no problem. A silently empty stream, not a diagnostic. Stored blocks are ordinary -- they are what a deflater emits for data that will not compress, which in a PDF is every image that arrived already compressed. Where the data starts is genuinely ambiguous, so it is now resolved by reading rather than guessing: every split point inside the leading white-space run is tried, and the read that yields bytes wins. "\r\n" before a stored block needs exactly two bytes skipped, not three, and no single rule gets both that and a bare stored block right. zlib is still tried only past the white-space, because a valid zlib stream cannot begin inside it: RFC 1950 fixes CM to 8, so the low nibble of the first byte is 8, and none of 00, 09, 0a, 0c, 0d, 20 has one. Also fixes TestDecodeRecoveringDamagedFlateKeepsPredictor, which asserted the recovered data was a prefix of the undamaged output. That only held because the halfway cut happened to fall on a row boundary. pngPredictor deliberately keeps a truncated final row, zero-filled -- TestPNGPredictorTruncatedRow pins that -- so the row the damage landed in cannot be a prefix of anything. The assertion now covers the whole rows and excludes the last. Found because Go 1.27's deflater stores this test's data where 1.26 compressed it. The library was wrong before that; nothing about it changed. 100% coverage under both 1.26.4 and 1.27.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
flateDecodeskips leading white-space before inflating, because producers put the stream's EOL there. NUL is one of the six bytes the specification calls white-space —isSpacesays so, correctly — and a bare deflate stream whose first block is stored begins with a NUL. So the skip ate a real byte.The failure was silent. Take
00 03 00 fc ff "abc", a stored block holdingabc. Drop its NUL and the remainder reads as a fixed-Huffman final block that ends immediately:"", no error"abc"An empty stream and a clean bill of health. That is worse than an error, because nothing downstream can tell it from a stream that really was empty.
Stored blocks are not a corner case. They are what a deflater emits for data that will not compress — in a PDF, every image that arrived already compressed.
The fix
Where the data begins is genuinely ambiguous, so it is now settled by reading rather than by guessing: every split point inside the leading white-space run is tried, and the read that actually yields bytes wins.
No single rule can do it.
"\r\n"before a stored block needs exactly two bytes skipped, not three; a bare stored block needs zero. Both cases are in the tests.zlib is still tried only past the white-space, and that is provable rather than assumed: RFC 1950 fixes
CMto 8, so the low nibble of a zlib stream's first byte is 8, and none of00 09 0a 0c 0d 20has a low nibble of 8. A valid zlib stream cannot begin inside a white-space run.The second failure
TestDecodeRecoveringDamagedFlateKeepsPredictorasserted the recovered bytes were a prefix of the undamaged output. That only ever held because the halfway cut fell on a row boundary.pngPredictordeliberately keeps a truncated final row, zero-filled —TestPNGPredictorTruncatedRowpins exactly that, and its comment says so. A row rebuilt from bytes that are not there cannot be a prefix of anything. The assertion now covers the whole rows and excludes the one the damage landed in.Measured: the truncated stream inflates to 627 bytes = 125 whole 5-byte rows plus 2 bytes, and the divergence was at byte 501 — the 126th row. Exactly the partial one.
How it surfaced
Go 1.27's deflater stores this test's data where 1.26 compressed it — 45 bytes starting
2a ce cf 4dbecame 46 starting00 27 00 d8. The library was wrong before that; nothing about it changed. This is whatgo-pdfkit/reader#22has been failing on.Verified
go vetclean;gofmtclean under both toolchains\r\n. Both are built by hand rather than by a deflater, because which block type a deflater picks is its own business and moves between releases — which is the whole lesson here.