Artifact 883c931a4ca9e11e9d893b56df451f3436d728adeff68a2463358be2885ed04b:
- File
docs/RELEASE-NOTES-0.3.29.md
— part of check-in
[362f18d357]
at
2026-09-23 18:33:13
on branch trunk
— inflate: a truncated dynamic-block header is the end of the stream, not a crash
tossBitsShort added the consumed bits where it must subtract them, so a header ending one byte short tossed past the end of the input and the next peek read a wrapped length off the stack. Found by the nightly soak four nights running. Release 0.3.29. (user: dmestas, size: 2826) [annotate] [blame] [check-ins using]
zit 0.3.29
zit 0.3.29 fixes a crash in the zlib decoder that every sync request and reply passes through. Any peer could trigger it with 32 bytes.
No artifact, tag namespace, sync message, or on-disk repository format changed.
A truncated dynamic-block header no longer crashes the decoder
The nightly fuzz soak failed four nights running on the same target,
sync_container_round_trip, each time with a different random seed and
each time as a bare segmentation fault. The cause was in the inflate zit
carries from Zig 0.16's standard library, and it's the same kind of slip as
the one the soak found there a week ago: end-of-input bit accounting.
Before tossing a code-length symbol, tossBitsShort checks that enough bits
remain. It computed them as bufferedLen() * 8 + consumed_bits, adding the
bits already consumed from the current byte where it has to subtract them.
With one byte left and six of its bits consumed, a seven-bit toss passed the
check and advanced the reader past its end. The next peek sliced
buffer[seek..end] with seek past end, got a length of 2⁶⁴−1, and read
up the stack until it hit an unmapped page.
Random garbage reaches it: the soak's input was 32 bytes whose dynamic-block
header ends one byte short. A peer could send the same bytes as a sync
request or reply. The check is now written without subtraction, like the
earlier fix, so a short header is just the end of the stream and decode
returns an error.
The regression test is the soak's own input. It panics a Debug build without the fix and passes with it. All four nights' seeds pass at the soak's round count. The vendored file's header now records both fixes.
A note on why it was hard to see. A unit test on the captured bytes passed
at first, because it used a 1 MiB output cap and these bytes declare 287 MB,
so decode refused them before the inflate ran. The fuzzer uses the
protocol's own reply cap. The test now admits the declared size.
The soak stops at its first failure
The four failures also hid something. The soak stops at the first failing
target, and sync_container_round_trip is sixteenth of twenty-eight. The
twelve after it, including transport_frame_garbage, which drives this same
decoder, and serve_request_garbage, got no deep run for four nights. A full
soak was dispatched with this release to cover them.
Compatibility
- No on-disk repository or wire format changed; Fossil RFC revision 00 remains the target.
- A container whose zlib stream ends partway through a dynamic block header is now refused with an error instead of crashing the process.
Qualification
zig fmt --check, zig build test, e2e, smoke, git-import-e2e,
git-export-e2e, dst, and every fuzz target at 256 rounds, plus the
three-runner CI battery and a full-depth soak.