Skip to content

Size compression bounds for incompressible input past 280 KB - #44

Merged
matt-edmondson merged 3 commits into
mainfrom
fix/compression-bound-incompressible
Sep 26, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
fix/compression-bound-incompressible

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #39

What changed

GetMaxCompressedLength assumed one 5-byte stored-block header per 64 KB. zlib closes a block each time its literal buffer fills, which is roughly every 16 KB. So for incompressible input above about 280 KB, the output grew past the bound. TryCompress then returned false into a buffer of that size, and Compress(byte[]) threw InvalidOperationException.

  • Deflate / Gzip / ZLib now use zlib's deflateBound for the default parameters: n + (n>>12) + (n>>14) + (n>>25) + 13, plus the container overhead (+18 for gzip, +6 for zlib). The bound is computed in long. Above int.MaxValue it now throws ArgumentOutOfRangeException instead of wrapping to a small or negative value.
  • Brotli now delegates to BrotliEncoder.GetMaxCompressedLength. That method also throws ArgumentOutOfRangeException when the length is negative or too large to bound. This is a small behaviour change: before, a nonsensical length returned a nonsensical value.

Measured on net10.0 (zlib-ng), raw deflate at Optimal with random input:

input actual new bound
280,224 B 280,314 280,322
1 MiB 1,048,896 1,048,909
10 MiB 10,488,960 10,488,973
100 MiB 104,889,590 104,889,616

The headroom is small, as expected: deflateBound is zlib's documented worst case for these parameters, not an estimate. If you want a wider safety margin for zlib builds other than the one .NET bundles, adding a constant is cheap. I kept to the documented bound.

Tests

  • Compression_Bound_Holds_For_Incompressible_Input now runs at 4 KB, 1 MiB and 10 MiB of random data for every compression provider.
  • New Compress_Succeeds_For_Large_Incompressible_Input covers the Compress(byte[]) path that used to throw.
  • New Compression_Bound_Does_Not_Overflow checks that a length near int.MaxValue either throws or returns a positive bound.
  • With the provider changes reverted, 10 of these fail: Deflate/Gzip/ZLib on the size checks, and all four on overflow.
  • Full suite: 912/912 pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NHprKRwyYopkJnUyhF3KSr


Generated by Claude Code

The Deflate, Gzip, ZLib and Brotli providers allowed one 5-byte block
header per 64 KB. zlib closes a block about every 16 KB, so random,
encrypted or already-compressed input above roughly 280 KB outgrew the
bound: TryCompress into a buffer of that size returned false and
Compress threw InvalidOperationException.

The deflate family now uses zlib's deflateBound for default parameters
plus each container's header and trailer, computed in long so a large
input throws instead of wrapping. Brotli delegates to
BrotliEncoder.GetMaxCompressedLength. The contract test now covers
1 MiB and 10 MiB of random data.

Fixes #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHprKRwyYopkJnUyhF3KSr
The three providers carried identical copies of the deflateBound
calculation, which SonarCloud's duplication gate rejected. It now lives
in Shared/DeflateBound.cs, linked into each project the way
HmacKeyedHashCore is, and each provider passes its container overhead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHprKRwyYopkJnUyhF3KSr
SonarCloud's copy-paste detection ignores literal values, so the three
one-line GetMaxCompressedLength bodies, which differed only in the
overhead number, still read as a duplicated block with the surrounding
provider code. Each provider now passes a named DeflateBound constant,
which also documents where 0, 6 and 18 come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHprKRwyYopkJnUyhF3KSr
@sonarqubecloud

Copy link
Copy Markdown

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.

Compress(byte[]) throws for incompressible input over ~280 KB: GetMaxCompressedLength undercounts Deflate/Gzip/ZLib overhead

2 participants