Repository navigation
Conversation
|
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 |
ac1f687 to
91cc017
Compare
…sis stubs Hypothesis lets a test pin a known-failing input with `@example(...).xfail(reason=..., raises=...)`, which is the natural way to record a bug found by a property test without taking the input out of the strategy or writing a separate expectedFailure test. The stub `example` is a plain decorator function, so any test using `.xfail()` or `.via()` failed to import when Hypothesis is not installed. This turns the stub into a class with those two methods. An expected failure is strict, as in Hypothesis: when the examples are replayed the test must raise one of the `raises` exceptions for that input, and an unexpected pass fails the test. `.via()` is a no-op, as it is upstream.
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.
…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.
Fuzzing the zstd tests with Atheris found that ZstdDecompressor raises ZstdError on the sixteenth consecutive decompress(b'', 0) call when a stream is buffered and its output is pending: the C loop calls ZSTD_decompressStream with an empty output buffer, and libzstd gives up after sixteen calls that make no progress. LZMADecompressor fails the same shape on the fourth call, a variant of pythongh-160000. test_repeated_max_length_zero only exercises a fresh decompressor, so this adds a companion with a whole stream fed first. It is an expectedFailure for lzma and zstd; bz2 and zlib pass.
Fuzzing the zlib tests with Atheris produced a zlib header with the
FDICT flag set within seconds, and zlib._ZlibDecompressor turns that
into SystemError ("error return without exception set") because the
Z_NEED_DICT branch returns NULL without an exception. That is the same
defect as pythongh-160001, reached without any dictionary at all. Random
invalid input hits it often enough to make test_invalid_input flaky for
the zlib wrapper backend, and in a debug build the missing exception is
a fatal C assertion rather than a failure.
The invalid-input check now asks the class whether to skip a blob, and
the zlib wrapper companion skips valid headers with FDICT set. The
carve-out is pinned by test_fdict_header, which expects zlib.error as
zlib.decompressobj raises for the same input, and which like
test_zlib_wrapper can only run in a release build.
Previously each known bug replaced the affected test for its backend with an expectedFailure version, so the test as a whole was expected to fail and nothing checked that the failure was the known one. With example.xfail() in the stubs (pythongh-160356), the known inputs are now expected-failure examples on the affected test: the generated input leaves the failing region out, as the comments say, and each example must fail, so the test fails again once the bug is fixed. For the needs_input bug (pythongh-159999) the generated calls for bz2 and zlib are unlimited, which is the case they get right; the examples depend on where the compressor puts block boundaries, so each has been checked to fail with both zlib 1.3.2 and zlib-ng 1.3.1, since the expected failures are strict. The FDICT examples (pythongh-160001) go on the zlib wrapper's test_invalid_input, which now clears the flag in generated garbage rather than skipping the input, and they are only registered in release builds, where the missing exception is a SystemError rather than a fatal assertion. The max_length=0 tests for lzma and zstd stop short of the failing call (pythongh-160000, pythongh-160121). Hypothesis also found that the broad zstd tests could reach pythongh-160121 with sixteen zero-length calls in a row, so zstd now shares the lzma carve-out of max_length=0, in the companions and the state machine. The subTest calls inside the invalid-input and zdict checks are gone: Hypothesis disables subTest under @given anyway, and inside the stub replay they swallowed the exception an expected-failure example has to raise.
bd9d844 to
c197707
Compare
Builds on #160356, which adds
example.xfail()to the stubs.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.Four bugs were found, not worked around: #159999, #160000 and #160001, plus #160121 found by fuzzing. Each is pinned as
@example(...).xfail(reason="gh-...")on the affected test for the affected backend, so the test fails once the input starts passing. Where the generated input has to leave the failing region out, the comments say so and point at the examples.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 by the examples ontest_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. Fuzzing later showed the same branch is reached without anyzdictby any zlib header with FDICT set, so the zlib wrapper'stest_invalid_inputclears the flag in generated garbage and leaves out a flipped byte that sets it; its expected-failure examples pin the bug, in release builds only, since the missing exception is a fatal C assertion in a debug build.ZstdDecompressorandLZMADecompressorraise on repeateddecompress(b'', max_length=0)calls with output pending #160121:ZstdDecompressorraises on the sixteenth consecutivedecompress(b'', 0)call with a stream buffered and output pending: the C loop callsZSTD_decompressStreamwith an empty output buffer and libzstd gives up after sixteen no-progress calls.LZMADecompressorfails the same shape on the fourth call (theLZMADecompressor.decompress(b'', max_length=0)raisesLZMAErroron the second consecutive call #160000 mechanism). Found by running the zstd tests under Atheris with_zstdcompiled with-fsanitize=fuzzer-no-link; pinned by the examples ontest_max_length_zero_with_pending_output. The broad zstd tests now leave outmax_length=0like the lzma ones, since sixteen zero-length calls in a row are easy to generate.One caveat on the examples for (1): the reproducers depend on exactly where the compressor places block boundaries, and the expected failures are strict, so each example has been checked to fail with both zlib 1.3.2 and zlib-ng 1.3.1 (the Windows CI builds use zlib-ng). A build against another fork could still turn one into an unexpected pass; the fix would be an example that fails on both.