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..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 @@ -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; @@ -28,12 +39,27 @@ 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); 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); } @@ -44,6 +70,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); 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..4974075e2a035a 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/CompressedContentTest.NonBrowser.cs @@ -3,10 +3,12 @@ using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; +using Microsoft.DotNet.XUnitExtensions; namespace System.Net.Http.Functional.Tests { @@ -104,15 +106,29 @@ public async Task BrotliZstd_SerializeToStream_WithOptions_RoundTrips(string enc Assert.Equal(original, DecompressBrotliOrZstd(await SerializeAsync(content, async: true), encoding)); } - [Theory] - [InlineData("br")] - [InlineData("zstd")] - public async Task BrotliZstd_SerializeToStream_WithCompressionLevel_RoundTrips(string encoding) + [ConditionalTheory] + [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)); + 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.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 +161,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)) };