Repository navigation
Conversation
There was a problem hiding this comment.
I don't think this needs a blurb, also there seem to be two?
|
Maybe these would be better suited for https://github.com/python/library-fuzzers, that way, there's more compute thrown at them? |
|
Sorry this was supposed to be a draft PR.
Hm... I don't love that these are in separate locations. I feel like one of the strengths of property tests is that they scale pretty well with compute — we use them as basically test parameterization via |
The incremental decompressors in bz2, lzma, zlib and compression.zstd,
the older zlib.decompressobj protocol, and the DecompressReader file
layer are CPython code sharing one contract, but the existing tests only
exercise them with fixed chunk sizes. This adds property tests that feed
random data through them in random pieces with random max_length values,
with the module-level one-shot functions as the oracle, plus a stateful
test per backend for the decompressor protocol and for the read-mode file
objects. The strategies avoid filters so the tests can also be used as
fuzz targets.
Writing these turned up three bugs, each pinned by an expectedFailure
test with the minimal reproducer as an example:
- bz2.BZ2Decompressor and zlib._ZlibDecompressor report needs_input=True
when max_length cut the output and more is still buffered.
- lzma.LZMADecompressor raises LZMAError("Insufficient buffer space") on
the second of two consecutive max_length=0 calls with no new input;
the other backends return b''. The broad lzma tests leave out
max_length=0 so that they keep checking everything else.
- zlib._ZlibDecompressor(zdict=...) raises SystemError on a zlib-wrapped
stream, because the Z_NEED_DICT path returns NULL without setting an
exception.
These ran 1000 iterations over random 32 and 64 byte inputs with unseeded random initial values, so a failure could not be reproduced. Hypothesis gives the same coverage with shrinking and a persistent example database. The Adler-32 initial values are now restricted to valid checksums (both 16-bit sums below 65521). adler32_combine() is only defined on those, and the old test only avoided the invalid region by chance, since random.randint(1, 0x80000000) rarely produced a low half of 65521 or more.
ac1f687 to
91cc017
Compare
…ession tests Review feedback on the first version: - Cycling through the per-call values (max_length, flush mode) is now itertools.cycle zipped with the chunks instead of a hand-rolled index helper. - The backend tables use functools.partial rather than lambdas wherever the call can be expressed that way. - The state machines checked with plain assert statements. They now go through a small base class that borrows the unittest assertion methods from a throwaway TestCase, so a failing rule reports the same diffs as everything else in the suite. The chunk splitter stays a custom generator: itertools.batched only makes equal-size pieces, and the point of the sizes list is to put the call boundaries at arbitrary positions in the stream (the pinned needs_input reproducers depend on that).
…xplicit The companion test classes stay ordinary class statements, since they are the ones with content worth reading and grepping for. The per-backend machine subclass only adds a backend attribute and Machine.TestCase is generated by Hypothesis anyway, so _machine_test() now builds both from a name, a machine base, a backend factory and a requires_* decorator. The backend factories return None when their module is missing, which removes the conditional backend assignments from every class. The per-backend overrides for the known bugs (and the lzma max_length=0 carve-out) are mixins listed first in the bases, so the MRO shows which backends carry one.
The test pinning pythongh-160001 expects a SystemError, but in a debug build the missing exception trips a C assertion in the call machinery before the SystemError is raised, which aborts the test worker. All CI builds are debug builds, so the test is skipped there until the bug is fixed. The gzip examples for pythongh-159999 also depend on where inflate stops consuming input, and none of them triggered the bug with zlib-ng, which the Windows builds use. This adds an example found against zlib-ng 2.2.4 in zlib-compat mode; the zlib wrapper and raw deflate examples already fail with both libraries. The comments now also reference the filed issues.
Builds on the
hypothesis.statefulstubs from #159770 (now merged).The compression modules wrap external libraries, but the incremental layer is CPython code with one shared contract:
decompress(data, max_length)witheof,needs_inputandunused_dataonBZ2Decompressor,LZMADecompressor,zlib._ZlibDecompressorandZstdDecompressor; the olderzlib.decompressobjprotocol withunconsumed_tail; and the read-mode file objects built onDecompressReader. The existing tests cover these with fixed chunk sizes only. This addsLib/test/test_compression_property.py:RuleBasedStateMachineper backend (bz2, gzip, lzma, zlib, raw deflate, zstd) that feeds one stream in random pieces with randommax_lengthvalues and checks the output prefix, themax_lengthbound,eof,unused_dataandEOFErroraftereof.@givencompanions of the same contract that replay under the stubs: chunked decompress, truncated streams, chunked compress round trips, repeatedmax_length=0, and invalid input (random bytes, a flipped byte, a truncated stream) where only the backend's own error type may escape.Z_*_FLUSH) and zstd (CONTINUE/FLUSH_BLOCK/FLUSH_FRAMEwithlast_mode), checked against the one-shot decompressors.zlib.decompressobjcontract (unconsumed_tail,unused_dataaftereof,flush()on truncated streams,zdict).BZ2File,GzipFile,LZMAFile,ZstdFile) over multi-member input, comparingread,read1,peek,readinto,readline,seekandtellagainstio.BytesIOon the plaintext.get_frame_size/get_frame_info, multi-frame streams, raw dictionaries in every loading mode, andset_pledged_input_size.The strategies use
.map()only (no filters orassume()), so every test is also usable as a fuzz target viafuzz_one_input. With Hypothesis the file takes about 8 s; under the stubs the machines skip and the explicit examples replay.It also converts the
ChecksumCombineMixinloops intest_zlib.py(3 × 1000 unseeded random iterations) to@given. The Adler-32 initial values are now drawn from valid checksums only, sinceadler32_combine()is only defined on those and the old loop avoided invalid ones by chance.Three bugs were found; each is recorded as an
expectedFailuretest with a minimal reproducer as an@example, not worked around: #159999, #160000 and #160001.BZ2Decompressorandzlib._ZlibDecompressorsetneeds_input=Truewhile output is still buffered #159999:bz2.BZ2Decompressorandzlib._ZlibDecompressorreportneeds_input=Truewhile output is still pending: when the input buffer empties they set it without checking whethermax_lengthcut the output.LZMADecompressorandZstdDecompressorget this right.DecompressReader.readtrustsneeds_input, so a reader can raiseEOFErrorwith data still buffered.LZMADecompressor.decompress(b'', max_length=0)raisesLZMAErroron the second consecutive call #160000:lzma.LZMADecompressorraises on the second of two consecutivemax_length=0calls with no new input; liblzma's "no progress"LZMA_BUF_ERRORleaks through. The other three backends returnb''. The broad lzma tests leave outmax_length=0so that they keep checking everything else; the carve-out is commented and pinned bytest_repeated_max_length_zero.zlib._ZlibDecompressor(zdict=...)raisesSystemErroron a zlib-wrapped stream #160001:zlib._ZlibDecompressor(zdict=...)on a zlib-wrapped stream raisesSystemError: ... returned NULL without setting an exception: the dictionary is only applied up front for raw deflate, and theZ_NEED_DICTbranch indecompress_bufjumps toerrorwithout setting one.zlib.decompressobjhandles the same input.One caveat on the
expectedFailuretests for (1): the reproducers depend on exactly where the compressor places block boundaries, so a build against a zlib fork with different output (zlib-ng, for example) could turn them into unexpected successes. If that happens on a buildbot the fix is to add an example found on that platform.