Repository navigation
Description
What's wrong
#80 made Gzip verify the CRC-32/ISIZE trailer so that cut-off input fails instead of returning partial data. To allow concatenated members, the check also accepts any input whose last 4 bytes, read as ISIZE, are smaller than the output produced so far (Shared/CompressedStreamEnd.cs:133-136):
uint trailerCrc = ReadUInt32LittleEndian(inputTail[^8..]);
uint trailerLength = ReadUInt32LittleEndian(inputTail[^4..]);
return (trailerCrc == ~crc && trailerLength == length) || trailerLength < length;When the input is truncated, its last 8 bytes aren't a trailer. They're whatever compressed bytes happened to sit at the cut. The || trailerLength < length branch therefore decides on arbitrary data and never checks a CRC.
All three paths use this check: the span path through IsComplete (:53-63), and the stream and async paths through CopyTo/CopyToAsync (:87, :112). When it matches, TryDecompress returns true with a prefix of the data, and Decompress(byte[]) returns that prefix without throwing. That is the failure #80 set out to remove.
How often this happens depends on the data:
- Compressible data: about
outputLength / 2^32per cut. That's small for small payloads, but roughly 1 in 4 at a 1 GiB output. - Data deflate leaves in stored blocks (already-compressed or encrypted content, images, archives, binaries): much more often. Stored blocks copy plaintext verbatim, so zero runs and small little-endian integers in the payload become the "trailer" when the input is cut there.
Repro (net10.0, HEAD 89fc373)
ICompressionProvider gzip = new GzipCompressionProvider();
byte[] plain = new byte[100_000];
new Random(42).NextBytes(plain);
plain.AsSpan(50_000, 4).Clear(); // a zeroed 4-byte field, as any binary file has
byte[] compressed = gzip.Compress(plain); // 100053 bytes (stored blocks)
int at = 20 + compressed.AsSpan(20).IndexOf(new byte[4]); // 50030
byte[] truncated = compressed[..(at + 4)]; // cut half way
gzip.TryDecompress(truncated, new byte[plain.Length], out int written);
gzip.TryDecompress(new MemoryStream(truncated), new MemoryStream());
await gzip.TryDecompressAsync(new MemoryStream(truncated), new MemoryStream());
gzip.Decompress(truncated);Observed:
span TryDecompress(truncated to 50034) = True, bytesWritten = 50004
stream TryDecompress = True, output = 50004 bytes
async TryDecompressAsync = True
Decompress(byte[]) returned 50004 bytes, no exception
Real data shows the same thing. I gzipped the first 300 KB of a .nupkg (a zip) and truncated it every 11 bytes. 2 of the 27,202 cuts returned true; the first returned 10,643 of 300,000 bytes. TruncatedCompressionTests misses this because it uses only 14 fixed cut points over one pseudo-random payload (Essentials.Tests/TruncatedCompressionTests.cs:108-118).
Why it matters
- A partial download or interrupted write of a
.gzcontaining binary or already-compressed content "succeeds" as a short file. - Persistence and cache layers that gzip records hand back truncated data with no error to catch.
- The remarks at
CompressedStreamEnd.cs:22-27and the CLAUDE.md entry for this file both promise that truncation is detected.
Suggested fix
Accept a multi-member stream only after verifying the last member's own CRC, instead of trusting trailerLength < length:
-
Span path: the whole output is available. When
trailerLength < length, compute CRC-32 overoutput[^trailerLength..]and require it to equaltrailerCrc. -
Stream/async paths: the output isn't kept. On net10,
GZipStream.Readreturns at each member boundary; two members of 100000 and 50000 bytes gave read ends…,98264,100000,150000. So:- Record
(length, crc)at eachReadreturn. - At the end, find the checkpoint whose length equals
total - trailerLength. - Derive the suffix CRC with a CRC-32 combine and compare it with
trailerCrc. - If no checkpoint matches, report failure.
If relying on read boundaries is too fragile, parse the member framing directly instead.
- Record
Acceptance criteria
- The repro returns
falseon the span, stream and async paths, writes 0 bytes on the span path, andDecompressthrows. - A test truncates a stored-block payload (random bytes with embedded zero runs) at every offset and asserts failure on all three paths.
Gzip_Accepts_Concatenated_Members(TruncatedCompressionTests.cs:98) still passes, and a concatenation whose last member is truncated is rejected.- ZLib and Brotli behaviour is unchanged.
Activity
matt-edmondson commentedon Nov 1, 2026
Triage
Shared/CompressedStreamEnd.cs(the Gzip trailer check), used by the span, stream and async pathsBoundedDecompression.Finishto hand a copy of the input to this sameIsCompletecheck. It does not fix this bug, but it touches the same call path, so land Check the Gzip and ZLib trailer against a copy when the source overlaps the destination [patch] #170 first or coordinate with it to avoid conflicts.trailerLength < lengthbranch.output[^trailerLength..]) first, since it is self-contained. Leave the stream-path checkpointing as a follow-up if it turns out to be too fragile.Generated by Claude Code