From 70e4987bc8e6d6ebd4ed506ca6f1909733ba4b7b Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 15 Jul 2026 19:32:50 +0200 Subject: [PATCH 1/5] Implement RFC 9659 compliance for "zstd" HttpContent compression --- .../Net/Http/ZstandardCompressedContent.cs | 22 ++++++++++++++++++- .../CompressedContentTest.NonBrowser.cs | 22 +++++++++++++------ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs index 20cb0b050db4c5..0a2d36cece4fba 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -18,6 +18,17 @@ public sealed class ZstandardCompressedContent : HttpContent { private const string Encoding = "zstd"; + // RFC 9659 requires zstd decoders for the "zstd" content coding to support a window size of at + // least 8 MB (2^23) and recommends that encoders not produce frames requiring a larger window. + // Some compression levels (notably CompressionLevel.SmallestSize) would otherwise select a larger + // window, producing payloads that a conformant server would reject. See RFC 9659, Section 3. + private const int RfcMaxWindowLog2 = 23; + private static readonly ZstandardCompressionOptions s_SmallestSizeRfcOptions = new ZstandardCompressionOptions + { + Quality = ZstandardCompressionOptions.MaxQuality, + WindowLog2 = RfcMaxWindowLog2 + }; + private readonly HttpContent _content; private readonly ZstandardCompressionOptions? _compressionOptions; private readonly CompressionLevel _compressionLevel; @@ -33,7 +44,16 @@ public ZstandardCompressedContent(HttpContent content, CompressionLevel compress ArgumentNullException.ThrowIfNull(content); CompressedContentCore.ValidateCompressionLevel(compressionLevel, nameof(compressionLevel)); - _compressionLevel = compressionLevel; + if (compressionLevel == CompressionLevel.SmallestSize) + { + // use RFC-compliant options for SmallestSize to avoid producing frames that a conformant server would reject + _compressionOptions = s_SmallestSizeRfcOptions; + } + else + { + _compressionLevel = compressionLevel; + } + _content = content; CompressedContentCore.InitializeHeaders(this, content, Encoding); } diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs index c2ac84bfa99f69..246353138f99d1 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; @@ -105,14 +106,20 @@ public async Task BrotliZstd_SerializeToStream_WithOptions_RoundTrips(string enc } [Theory] - [InlineData("br")] - [InlineData("zstd")] - public async Task BrotliZstd_SerializeToStream_WithCompressionLevel_RoundTrips(string encoding) + [InlineData("br", CompressionLevel.NoCompression)] + [InlineData("br", CompressionLevel.Fastest)] + [InlineData("br", CompressionLevel.Optimal)] + [InlineData("br", CompressionLevel.SmallestSize)] + [InlineData("zstd", CompressionLevel.NoCompression)] + [InlineData("zstd", CompressionLevel.Fastest)] + [InlineData("zstd", CompressionLevel.Optimal)] + [InlineData("zstd", CompressionLevel.SmallestSize)] + public async Task BrotliZstd_SerializeToStream_WithCompressionLevel_RoundTrips(string encoding, CompressionLevel compressionLevel) { - byte[] original = Encoding.UTF8.GetBytes(new string('a', 4096)); + byte[] original = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("The quick brown fox jumps over the lazy dog. ", 4096))); HttpContent content = encoding == "br" - ? new BrotliCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize) - : new ZstandardCompressedContent(new ByteArrayContent(original), CompressionLevel.SmallestSize); + ? new BrotliCompressedContent(new ByteArrayContent(original), compressionLevel) + : new ZstandardCompressedContent(new ByteArrayContent(original), compressionLevel); Assert.Equal(original, DecompressBrotliOrZstd(await SerializeAsync(content, async: true), encoding)); } @@ -145,7 +152,8 @@ private static byte[] DecompressBrotliOrZstd(byte[] compressed, string encoding) using Stream decompressor = encoding switch { "br" => new BrotliStream(source, CompressionMode.Decompress), - "zstd" => new ZstandardStream(source, CompressionMode.Decompress), + // RFC 9659 requires the "zstd" content coding to be decodable with an 8 MB (2^23) window. + "zstd" => new ZstandardStream(source, new ZstandardDecompressionOptions { MaxWindowLog2 = 23 }), _ => throw new ArgumentOutOfRangeException(nameof(encoding)) }; From e9b8fb93913e076189a4219589bbada9b3b5d205 Mon Sep 17 00:00:00 2001 From: Radek Zikmund <32671551+rzikm@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:01:37 +0200 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Radek Zikmund <32671551+rzikm@users.noreply.github.com> --- .../src/System/Net/Http/ZstandardCompressedContent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs index 0a2d36cece4fba..f6902d043e4644 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -23,7 +23,7 @@ public sealed class ZstandardCompressedContent : HttpContent // Some compression levels (notably CompressionLevel.SmallestSize) would otherwise select a larger // window, producing payloads that a conformant server would reject. See RFC 9659, Section 3. private const int RfcMaxWindowLog2 = 23; - private static readonly ZstandardCompressionOptions s_SmallestSizeRfcOptions = new ZstandardCompressionOptions + private static readonly ZstandardCompressionOptions s_smallestSizeRfcOptions = new ZstandardCompressionOptions { Quality = ZstandardCompressionOptions.MaxQuality, WindowLog2 = RfcMaxWindowLog2 @@ -47,7 +47,7 @@ public ZstandardCompressedContent(HttpContent content, CompressionLevel compress if (compressionLevel == CompressionLevel.SmallestSize) { // use RFC-compliant options for SmallestSize to avoid producing frames that a conformant server would reject - _compressionOptions = s_SmallestSizeRfcOptions; + _compressionOptions = s_smallestSizeRfcOptions; } else { From 341d17375706c71ac4432dfd637e62b5d729e6a4 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Mon, 20 Jul 2026 10:27:44 +0200 Subject: [PATCH 3/5] Add remarks section --- .../src/System/Net/Http/ZstandardCompressedContent.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs index f6902d043e4644..6b6b2cca1a40a8 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -64,6 +64,12 @@ public ZstandardCompressedContent(HttpContent content, CompressionLevel compress /// /// The HTTP content to compress. /// The options used to fine-tune the compression. + /// + /// RFC 9659 requires that the "zstd" content coding be decodable with a window size of 8 MB (2^23) and + /// recommends that encoders not produce frames requiring a larger window. When supplying custom options, + /// consider limiting to 23 or less so that the + /// produced content is accepted by servers that enforce this limit. + /// public ZstandardCompressedContent(HttpContent content, ZstandardCompressionOptions compressionOptions) { ArgumentNullException.ThrowIfNull(content); From 9db9336f4bf3637c990326d6a304e9530e3c28e2 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Mon, 20 Jul 2026 10:28:10 +0200 Subject: [PATCH 4/5] Fix OOM on x86 runs --- .../CompressedContentTest.NonBrowser.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs index 246353138f99d1..4974075e2a035a 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Xunit; +using Microsoft.DotNet.XUnitExtensions; namespace System.Net.Http.Functional.Tests { @@ -105,7 +106,7 @@ public async Task BrotliZstd_SerializeToStream_WithOptions_RoundTrips(string enc Assert.Equal(original, DecompressBrotliOrZstd(await SerializeAsync(content, async: true), encoding)); } - [Theory] + [ConditionalTheory] [InlineData("br", CompressionLevel.NoCompression)] [InlineData("br", CompressionLevel.Fastest)] [InlineData("br", CompressionLevel.Optimal)] @@ -116,6 +117,14 @@ public async Task BrotliZstd_SerializeToStream_WithOptions_RoundTrips(string enc [InlineData("zstd", CompressionLevel.SmallestSize)] public async Task BrotliZstd_SerializeToStream_WithCompressionLevel_RoundTrips(string encoding, CompressionLevel compressionLevel) { + if (PlatformDetection.Is32BitProcess && compressionLevel == CompressionLevel.SmallestSize && encoding == "zstd") + { + // Zstandard smallest size requires too much working memory + // (800+ MB) and causes intermittent allocation errors on 32-bit + // processes in CI. + throw new SkipTestException($"Skipping {encoding} with {compressionLevel} on 32-bit process due to excessive memory requirements."); + } + byte[] original = Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("The quick brown fox jumps over the lazy dog. ", 4096))); HttpContent content = encoding == "br" ? new BrotliCompressedContent(new ByteArrayContent(original), compressionLevel) From f8bf24f8cb9c2bc3a26073784f12facd1f742a66 Mon Sep 17 00:00:00 2001 From: Radek Zikmund <32671551+rzikm@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:46:38 +0200 Subject: [PATCH 5/5] Apply suggestion from @am11 Co-authored-by: Adeel Mujahid <3840695+am11@users.noreply.github.com> --- .../src/System/Net/Http/ZstandardCompressedContent.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs index 6b6b2cca1a40a8..47f36bb5692fcc 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/ZstandardCompressedContent.cs @@ -39,6 +39,12 @@ public sealed class ZstandardCompressedContent : HttpContent /// /// The HTTP content to compress. /// One of the enumeration values that indicates whether to emphasize speed or compression efficiency. + /// + /// RFC 9659 requires that the "zstd" content coding be decodable with a window size of 8 MB (2^23) and + /// recommends that encoders not produce frames requiring a larger window. When setting + /// to , this class applies RFC-compliant options to limit the window size + /// so that the produced content is accepted by servers that enforce this limit. + /// public ZstandardCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Optimal) { ArgumentNullException.ThrowIfNull(content);