Skip to content

FlateDecode: a stored first block begins with NUL, which is white-space - #23

Merged
tannevaled merged 1 commit into
mainfrom
flate-nul
Aug 30, 2026
Merged

FlateDecode: a stored first block begins with NUL, which is white-space#23
tannevaled merged 1 commit into
mainfrom
flate-nul

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

flateDecode skips leading white-space before inflating, because producers put the stream's EOL there. NUL is one of the six bytes the specification calls white-spaceisSpace says 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 holding abc. Drop its NUL and the remainder reads as a fixed-Huffman final block that ends immediately:

result
before "", no error
after "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 CM to 8, so the low nibble of a zlib stream's first byte is 8, and none of 00 09 0a 0c 0d 20 has a low nibble of 8. A valid zlib stream cannot begin inside a white-space run.

The second failure

TestDecodeRecoveringDamagedFlateKeepsPredictor asserted the recovered bytes were a prefix of the undamaged output. That only ever held because the halfway cut fell on a row boundary.

pngPredictor deliberately keeps a truncated final row, zero-filled — TestPNGPredictorTruncatedRow pins 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 4d became 46 starting 00 27 00 d8. The library was wrong before that; nothing about it changed. This is what go-pdfkit/reader#22 has been failing on.

Verified

  • full suite green under go1.26.4 and go1.27.0
  • 100.0% of statements under both, which the repo's exact gate requires
  • go vet clean; gofmt clean under both toolchains
  • new tests: a hand-built stored block starting with NUL, and a raw deflate stream behind a \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.

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>
@tannevaled
tannevaled merged commit 622fc04 into main Aug 30, 2026
1 check passed
@tannevaled
tannevaled deleted the flate-nul branch August 30, 2026 15:35
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