From a2e47ca7dae54f53dc8994b2909b6b42e1c5d04f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 15:55:51 +0000 Subject: [PATCH 1/4] Bound chunked decoding: stream chunk data, cap the trailer section ParserLimits caps every part of the header block, but nothing bounded a chunked body's framing: - ChunkedBodyStream returned NeedMoreData until a whole chunk was buffered, so a peer that declares FFFFFFFF and keeps sending makes the caller's buffer grow without limit. - The trailer section had no cap at all. Endless "X: y" lines after the last chunk bypass MaxTotalHeaderBytes, and because NeedMoreData rolls back, the decoder asks for the whole section to be re-presented until it ends - so it is buffered too. Chunk data is now handed out as it arrives, in both the span and the ReadOnlySequence overloads: a Chunk result carries a whole chunk or the part of one that has arrived, and is never empty. Data that is complete before its CRLF is handed out as well; the CRLF is checked on the next call. NeedMoreData keeps its contract - nothing consumed, state rolled back - and now only occurs when no data byte is available. Buffering is bounded by the caller's read window instead of the declared size, which is how the C decoder already behaves. Callers that append each Chunk's bytes and advance by bytesConsumed (GenHTTP's ChunkedFraming does) need no change. The trailer section is capped by the new ParserLimits.MaxTotalTrailerBytes (default 16 KiB): every trailer field line after the last chunk, including its CRLF. Exceeding it throws HttpParseException with status 431 from the span overload, the sequence overload and ChunkedBodyValidator. The bytes seen so far are checked even while the section is incomplete, so an endless section is refused once it passes the cap rather than buffered; a CR at the very end of the input is not counted yet, since it may be the first half of the final CRLF. ChunkedBodyStream takes the limits through a new constructor and ChunkedBodyValidator through a new TryValidate overload; new ChunkedBodyStream() and the existing overload apply ParserLimits.Default. No MaxChunkSize: with data streamed, a per-chunk cap would only reject legitimate large chunks - the total body size is the application's limit. Fixes #63. --- README.md | 4 + SECURITY.md | 1 + site/glyph11.html | 11 +- src/Glyph11/Parser/ChunkedBodyStream.cs | 155 ++++- src/Glyph11/Parser/ParserLimits.cs | 11 +- .../Validation/ChunkedBodyValidator.cs | 25 +- tests/Tests/ChunkedBodyStream.Limits.cs | 529 ++++++++++++++++++ tests/Tests/ChunkedBodyStream.Sequence.cs | 5 +- tests/Tests/ChunkedBodyStream.Tests.cs | 25 +- 9 files changed, 729 insertions(+), 37 deletions(-) create mode 100644 tests/Tests/ChunkedBodyStream.Limits.cs diff --git a/README.md b/README.md index 84b39d0..d418398 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ var r = stream.TryReadChunk(input, out int consumed, out int dataOffset, out int // r: Chunk | Completed | NeedMoreData | Error ``` +Payload is handed out as it arrives (a `Chunk` result may be part of a chunk), so a peer's +declared chunk size never decides how much you buffer; the trailer section is capped by +`ParserLimits.MaxTotalTrailerBytes` (16 KiB by default; `new ChunkedBodyStream(limits)`). + ## Benchmarks `linux-x64`, best-of-5, ns/op (lower is better). `Glyph11.Native` / `Glyph11.Pico` are the diff --git a/SECURITY.md b/SECURITY.md index 0e49834..387ceb9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -121,6 +121,7 @@ All limits are configurable via `ParserLimits`: | `MaxQueryParameterCount` | 128 | Query parameter flooding | | `MaxMethodLength` | 16 | Oversized method strings | | `MaxTotalHeaderBytes` | 1048576 | Total header section DoS | +| `MaxTotalTrailerBytes` | 16384 | Endless chunked trailer section (applies to `ChunkedBodyStream` / `ChunkedBodyValidator`) | --- diff --git a/site/glyph11.html b/site/glyph11.html index 48a30a0..510d4fa 100644 --- a/site/glyph11.html +++ b/site/glyph11.html @@ -145,7 +145,8 @@

Limits & configuration

}; // Available knobs (all int): MaxHeaderCount, MaxHeaderNameLength, MaxHeaderValueLength, -// MaxUrlLength, MaxQueryParameterCount, MaxMethodLength, MaxTotalHeaderBytes. +// MaxUrlLength, MaxQueryParameterCount, MaxMethodLength, MaxTotalHeaderBytes, +// MaxTotalTrailerBytes (chunked trailer section; pass limits to new ChunkedBodyStream(limits)).

Reading from a PipeReader

The real-world loop: read, try to parse, and if the buffer doesn't yet hold a full header block, @@ -186,7 +187,9 @@

Reading from a PipeReader

Chunked bodies

After the headers, a Transfer-Encoding: chunked body is decoded with ChunkedBodyStream — a small struct state machine you feed bytes to. It tells you where each - decoded chunk's payload sits, without copying.

+ decoded chunk's payload sits, without copying. Payload is handed out as it arrives, so a peer's + declared chunk size never decides how much you buffer, and the trailer section is capped by + ParserLimits.MaxTotalTrailerBytes.

using Glyph11.Parser;
 
 var decoder = new ChunkedBodyStream();
@@ -204,13 +207,13 @@ 

Chunked bodies

switch (r) { - case ChunkResult.Chunk: // a payload chunk is ready + case ChunkResult.Chunk: // payload bytes are ready: a whole chunk or the part that arrived body.Write(input.AsSpan(offset + dataOffset, dataLength)); offset += consumed; continue; case ChunkResult.Completed: // terminal 0-length chunk seen → body done break; - case ChunkResult.NeedMoreData: // partial chunk — read more bytes, then call again + case ChunkResult.NeedMoreData: // incomplete framing — read more bytes, then call again break; case ChunkResult.Error: // malformed chunk framing → reject the request throw new InvalidOperationException("bad chunked encoding"); diff --git a/src/Glyph11/Parser/ChunkedBodyStream.cs b/src/Glyph11/Parser/ChunkedBodyStream.cs index 162d691..b772521 100644 --- a/src/Glyph11/Parser/ChunkedBodyStream.cs +++ b/src/Glyph11/Parser/ChunkedBodyStream.cs @@ -10,7 +10,10 @@ public enum ChunkResult : byte /// Incomplete data — wait for more bytes from the pipe. NeedMoreData, - /// A complete chunk's decoded payload is available. + /// + /// Decoded payload bytes are available: a whole chunk, or the part of one that has arrived so + /// far (never empty). Chunk boundaries carry no meaning, so append the bytes and call again. + /// Chunk, /// Terminal chunk (and any trailers) consumed — body is done. @@ -21,6 +24,11 @@ public enum ChunkResult : byte /// Stateful streaming parser for chunked transfer-encoding (RFC 9112 §7.1). /// Validates chunks incrementally so the caller can advance a PipeReader /// after each call without re-parsing from byte 0. +/// +/// Chunk data is handed out as it arrives rather than once the whole chunk is buffered, so the +/// caller's buffer is bounded by its read window, not by the size a peer declares. The trailer +/// section is capped by . +/// /// public struct ChunkedBodyStream { @@ -42,6 +50,22 @@ private enum Phase : byte private long _remaining; // bytes left in ChunkData phase private int _digitCount; // hex digits seen in ChunkSize private int _extensionBytes; // extension bytes (4096 limit) + private int _maxTrailerBytes; // trailer section cap, when constructed with limits + private bool _hasLimits; // false for new ChunkedBodyStream(): ParserLimits.Default applies + + /// + /// Creates a decoder that enforces (today: + /// ). new ChunkedBodyStream() applies + /// . + /// + public ChunkedBodyStream(in ParserLimits limits) : this() + { + _maxTrailerBytes = limits.MaxTotalTrailerBytes; + _hasLimits = true; + } + + private readonly int MaxTrailerBytes + => _hasLimits ? _maxTrailerBytes : ParserLimits.Default.MaxTotalTrailerBytes; /// /// Reads the next chunk from . @@ -52,11 +76,16 @@ private enum Phase : byte /// Offset within where chunk payload starts (valid when result is ). /// Length of chunk payload bytes (valid when result is ). /// - /// : payload at input[dataOffset..dataOffset+dataLength], advance by bytesConsumed.
+ /// : payload at input[dataOffset..dataOffset+dataLength] — a whole chunk or the + /// part of one that has arrived, never empty — advance by bytesConsumed.
/// : terminal chunk consumed, body is done.
- /// : incomplete, wait for more from pipe. + /// : incomplete, nothing consumed — wait for more from pipe and + /// re-present the same bytes. ///
- /// Thrown on malformed chunked encoding. + /// + /// Thrown on malformed chunked encoding, or (status 431) when the trailer section exceeds + /// . + /// public ChunkResult TryReadChunk( ReadOnlySpan input, out int bytesConsumed, @@ -67,6 +96,7 @@ public ChunkResult TryReadChunk( dataOffset = 0; dataLength = 0; int pos = 0; + int trailerStart = 0; // where the trailer section begins; it is always parsed within one call // Snapshot state so we can roll back on NeedMoreData. // The caller re-presents the same bytes, so partial state must not persist. @@ -169,6 +199,7 @@ public ChunkResult TryReadChunk( { // Terminal chunk — move to trailers _phase = Phase.Trailers; + trailerStart = pos; continue; } @@ -180,16 +211,22 @@ public ChunkResult TryReadChunk( case Phase.ChunkData: { - int available = input.Length - pos; + int available = input.Length - pos; // >= 1: the loop only runs while input remains if (available < _remaining) { - this = snapshot; - return ChunkResult.NeedMoreData; + // Hand out what has arrived instead of waiting for the whole chunk: the + // caller's buffer is then bounded by its read window, not by whatever size + // the peer declared. The rest of the chunk follows on later calls. + dataOffset = pos; + dataLength = available; + _remaining -= available; + bytesConsumed = input.Length; + return ChunkResult.Chunk; } - // All chunk data is present + // The rest of the chunk's data is present dataOffset = pos; - dataLength = (int)_chunkSize; + dataLength = (int)_remaining; pos += (int)_remaining; _remaining = 0; _phase = Phase.DataCrlf; @@ -200,6 +237,14 @@ public ChunkResult TryReadChunk( { if (pos + 1 >= input.Length) { + // The CRLF after the data has not arrived. Data read by this call is + // handed out anyway — the CRLF is checked on the next call. + if (dataLength > 0) + { + bytesConsumed = pos; + return ChunkResult.Chunk; + } + this = snapshot; return ChunkResult.NeedMoreData; } @@ -218,8 +263,14 @@ public ChunkResult TryReadChunk( _digitCount = 0; _extensionBytes = 0; - bytesConsumed = pos; - return ChunkResult.Chunk; + if (dataLength > 0) + { + bytesConsumed = pos; + return ChunkResult.Chunk; + } + + // The data was handed out by an earlier call: go on to the next chunk. + continue; } case Phase.Trailers: @@ -254,6 +305,16 @@ public ChunkResult TryReadChunk( pos++; } + // The trailer section is re-presented from its start until it completes, so + // cap the bytes seen so far — finished or not — or an endless section is + // buffered forever. A CR that ends the input may be half of the final CRLF, + // so it is not counted yet. + int seen = pos - trailerStart; + if (pos >= input.Length && input[pos - 1] == (byte)'\r') + seen--; + if (seen > MaxTrailerBytes) + throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431); + if (pos >= input.Length) { this = snapshot; @@ -271,7 +332,14 @@ public ChunkResult TryReadChunk( } } - // Ran out of input + // Ran out of input. A chunk whose data is all here but whose CRLF is not is still + // handed out (the CRLF is checked on the next call); anything else waits. + if (dataLength > 0) + { + bytesConsumed = pos; + return ChunkResult.Chunk; + } + this = snapshot; return ChunkResult.NeedMoreData; } @@ -280,9 +348,14 @@ public ChunkResult TryReadChunk( /// Multi-segment overload of . /// Reads the next chunk directly from a — no linearization. /// On , is the decoded payload as a - /// (possibly multi-segment) slice of ; copy it out before advancing the - /// reader past . + /// (possibly multi-segment) slice of — a whole chunk or the part of one + /// that has arrived, never empty; copy it out before advancing the reader past + /// . ///
+ /// + /// Thrown on malformed chunked encoding, or (status 431) when the trailer section exceeds + /// . + /// public ChunkResult TryReadChunk( in ReadOnlySequence input, out long bytesConsumed, @@ -294,6 +367,7 @@ public ChunkResult TryReadChunk( var reader = new SequenceReader(input); var snapshot = this; // roll back on NeedMoreData — caller re-presents the bytes ReadOnlySequence pending = default; + long trailerStart = 0; // the trailer section is always parsed within one call while (!reader.End) { @@ -374,6 +448,7 @@ public ChunkResult TryReadChunk( if (_chunkSize == 0) { _phase = Phase.Trailers; + trailerStart = reader.Consumed; continue; } _remaining = _chunkSize; @@ -385,8 +460,14 @@ public ChunkResult TryReadChunk( { if (reader.Remaining < _remaining) { - this = snapshot; - return ChunkResult.NeedMoreData; + // Hand out what has arrived (at least one byte: the loop runs only while + // input remains) instead of buffering the whole declared chunk. + long available = reader.Remaining; + chunkData = reader.UnreadSequence; + reader.Advance(available); + _remaining -= available; + bytesConsumed = reader.Consumed; + return ChunkResult.Chunk; } pending = reader.UnreadSequence.Slice(0, _remaining); reader.Advance(_remaining); @@ -399,6 +480,15 @@ public ChunkResult TryReadChunk( { if (reader.Remaining < 2) { + // The CRLF after the data has not arrived: hand out data read by this call + // anyway — the CRLF is checked on the next call. + if (!pending.IsEmpty) + { + bytesConsumed = reader.Consumed; + chunkData = pending; + return ChunkResult.Chunk; + } + this = snapshot; return ChunkResult.NeedMoreData; } @@ -413,9 +503,15 @@ public ChunkResult TryReadChunk( _digitCount = 0; _extensionBytes = 0; - bytesConsumed = reader.Consumed; - chunkData = pending; - return ChunkResult.Chunk; + if (!pending.IsEmpty) + { + bytesConsumed = reader.Consumed; + chunkData = pending; + return ChunkResult.Chunk; + } + + // The data was handed out by an earlier call: go on to the next chunk. + continue; } case Phase.Trailers: @@ -435,11 +531,23 @@ public ChunkResult TryReadChunk( // a trailer line, up to CRLF; a bare LF inside it is invalid if (!reader.TryReadTo(out ReadOnlySequence line, ParserConstants.Crlf, advancePastDelimiter: true)) { + // Re-presented from the start of the section until it completes: cap the + // bytes seen so far, or an endless trailer line is buffered forever. A CR + // that ends the input may be half of the final CRLF: not counted yet. + var unread = reader.UnreadSequence; + long seen = reader.Consumed - trailerStart + unread.Length; + if (unread.Slice(unread.Length - 1).FirstSpan[0] == (byte)'\r') + seen--; + if (seen > MaxTrailerBytes) + throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431); + this = snapshot; return ChunkResult.NeedMoreData; } if (line.PositionOf((byte)'\n') is not null) throw new HttpParseException("Bare LF in chunked trailer."); + if (reader.Consumed - trailerStart > MaxTrailerBytes) + throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431); continue; } @@ -451,6 +559,15 @@ public ChunkResult TryReadChunk( } } + // Ran out of input. A chunk whose data is all here but whose CRLF is not is still + // handed out (the CRLF is checked on the next call); anything else waits. + if (!pending.IsEmpty) + { + bytesConsumed = reader.Consumed; + chunkData = pending; + return ChunkResult.Chunk; + } + this = snapshot; return ChunkResult.NeedMoreData; } diff --git a/src/Glyph11/Parser/ParserLimits.cs b/src/Glyph11/Parser/ParserLimits.cs index f118c1d..211c231 100644 --- a/src/Glyph11/Parser/ParserLimits.cs +++ b/src/Glyph11/Parser/ParserLimits.cs @@ -36,6 +36,14 @@ public readonly record struct ParserLimits /// Maximum total size of the header block including request line and terminators (default 1048576). public int MaxTotalHeaderBytes { get; init; } + /// + /// Maximum total size of a chunked body's trailer section in bytes: every trailer field line + /// after the last chunk, including its CRLF (default 16384). Enforced by + /// and ; exceeding + /// it is a 431, like an oversized header block. + /// + public int MaxTotalTrailerBytes { get; init; } + /// Returns a with safe production defaults. public static ParserLimits Default => new() { @@ -46,6 +54,7 @@ public readonly record struct ParserLimits MaxQueryParameterCount = 128, MaxMethodLength = 16, MaxReasonPhraseLength = 512, - MaxTotalHeaderBytes = 1_048_576 + MaxTotalHeaderBytes = 1_048_576, + MaxTotalTrailerBytes = 16_384 }; } diff --git a/src/Glyph11/Validation/ChunkedBodyValidator.cs b/src/Glyph11/Validation/ChunkedBodyValidator.cs index b17bd06..723f9c1 100644 --- a/src/Glyph11/Validation/ChunkedBodyValidator.cs +++ b/src/Glyph11/Validation/ChunkedBodyValidator.cs @@ -1,3 +1,4 @@ +using Glyph11.Parser; namespace Glyph11.Validation; @@ -9,12 +10,23 @@ public static class ChunkedBodyValidator private const int MaxChunkExtensionBytes = 4096; /// - /// Validates chunked body format. + /// Validates chunked body format under . /// Returns true if valid and complete. /// Returns false if incomplete (need more data). /// Throws if malformed. /// public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed) + => TryValidate(body, ParserLimits.Default, out bytesConsumed); + + /// + /// Validates chunked body format, enforcing (today: + /// ). + /// Returns true if valid and complete. + /// Returns false if incomplete (need more data). + /// Throws if malformed, or with status 431 when the trailer + /// section exceeds the limit. + /// + public static bool TryValidate(ReadOnlySpan body, in ParserLimits limits, out int bytesConsumed) { bytesConsumed = 0; int pos = 0; @@ -110,6 +122,8 @@ public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed) // --- Last chunk (size == 0): parse optional trailers --- if (chunkSize == 0) { + int trailerStart = pos; + // Trailers: zero or more header lines, terminated by CRLF while (true) { @@ -145,6 +159,15 @@ public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed) pos++; } + // Cap the trailer section — the bytes seen so far, finished or not, so an + // endless one is refused rather than waited on. A CR that ends the input may + // be half of the final CRLF, so it is not counted yet. + int seen = pos - trailerStart; + if (!foundCrlf && body[pos - 1] == (byte)'\r') + seen--; + if (seen > limits.MaxTotalTrailerBytes) + throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431); + if (!foundCrlf) return false; // incomplete trailer line } diff --git a/tests/Tests/ChunkedBodyStream.Limits.cs b/tests/Tests/ChunkedBodyStream.Limits.cs new file mode 100644 index 0000000..e73a7c3 --- /dev/null +++ b/tests/Tests/ChunkedBodyStream.Limits.cs @@ -0,0 +1,529 @@ +using System.Buffers; +using System.Text; +using Glyph11; +using Glyph11.Parser; +using Glyph11.Utils; +using Glyph11.Validation; + +namespace Tests; + +/// +/// Resource ceilings of chunked decoding: chunk data is handed out as it arrives (so buffering is +/// bounded by the read window, not by the declared chunk size), and the trailer section is capped +/// by . +/// +public class ChunkedBodyStreamLimitsTests +{ + private const int DefaultTrailerCap = 16_384; + + private static byte[] B(string s) => Encoding.ASCII.GetBytes(s); + + private static ReadOnlySequence Segmented(byte[] data, int segments) + { + if (segments <= 1 || data.Length <= 1) + return new ReadOnlySequence(data); + + int size = Math.Max(1, data.Length / segments); + BufferSegment? first = null, last = null; + for (int pos = 0; pos < data.Length; pos += size) + { + var mem = data.AsMemory(pos, Math.Min(size, data.Length - pos)); + if (first is null) { first = new BufferSegment(mem); last = first; } + else last = last!.Append(mem); + } + return new ReadOnlySequence(first!, 0, last!, last!.Memory.Length); + } + + // ---- drivers: behave like a PipeReader loop — only unconsumed bytes stay buffered ---- + + private sealed class Feed + { + public readonly List Body = new(); + public int MaxBuffered; + public int Fed; + public int Leftover; + } + + private static Feed FeedSpan(ChunkedBodyStream stream, byte[] input, Func readSize) + { + var f = new Feed(); + var buffer = new List(); + while (true) + { + if (buffer.Count > 0) + { + var span = buffer.ToArray(); + var r = stream.TryReadChunk(span, out var consumed, out var off, out var len); + if (r == ChunkResult.Chunk) + { + Assert.True(len > 0, "a Chunk result must carry data"); + f.Body.AddRange(span.AsSpan(off, len).ToArray()); + buffer.RemoveRange(0, consumed); + continue; + } + if (r == ChunkResult.Completed) + { + buffer.RemoveRange(0, consumed); + f.Leftover = buffer.Count + (input.Length - f.Fed); + return f; + } + Assert.Equal(0, consumed); // NeedMoreData consumes nothing + } + + if (f.Fed == input.Length) + throw new Xunit.Sdk.XunitException("input ended before the body completed"); + + int n = Math.Min(readSize(), input.Length - f.Fed); + buffer.AddRange(input.AsSpan(f.Fed, n).ToArray()); + f.Fed += n; + f.MaxBuffered = Math.Max(f.MaxBuffered, buffer.Count); + } + } + + private static Feed FeedSequence(ChunkedBodyStream stream, byte[] input, Func readSize, int segments = 3) + { + var f = new Feed(); + var buffer = new List(); + while (true) + { + if (buffer.Count > 0) + { + var seq = Segmented(buffer.ToArray(), segments); + var r = stream.TryReadChunk(seq, out var consumed, out var data); + if (r == ChunkResult.Chunk) + { + Assert.True(data.Length > 0, "a Chunk result must carry data"); + f.Body.AddRange(data.ToArray()); + buffer.RemoveRange(0, (int)consumed); + continue; + } + if (r == ChunkResult.Completed) + { + buffer.RemoveRange(0, (int)consumed); + f.Leftover = buffer.Count + (input.Length - f.Fed); + return f; + } + Assert.Equal(0, consumed); + } + + if (f.Fed == input.Length) + throw new Xunit.Sdk.XunitException("input ended before the body completed"); + + int n = Math.Min(readSize(), input.Length - f.Fed); + buffer.AddRange(input.AsSpan(f.Fed, n).ToArray()); + f.Fed += n; + f.MaxBuffered = Math.Max(f.MaxBuffered, buffer.Count); + } + } + + private static Func Fixed(int n) => () => n; + + private static Func RandomReads(int seed, int max) + { + var rng = new Random(seed); + return () => rng.Next(1, max + 1); + } + + /// Encodes as chunks; returns the wire bytes and the decoded body. + private static (byte[] wire, byte[] body) Chunked(IEnumerable pieces, string trailers = "") + { + var wire = new StringBuilder(); + var body = new StringBuilder(); + foreach (var p in pieces) + { + wire.Append(p.Length.ToString("x")).Append("\r\n").Append(p).Append("\r\n"); + body.Append(p); + } + wire.Append("0\r\n").Append(trailers).Append("\r\n"); + return (B(wire.ToString()), B(body.ToString())); + } + + public static IEnumerable Bodies() + { + yield return new object[] { new[] { "Hello" }, "" }; + yield return new object[] { new[] { "Hello", " World" }, "" }; + yield return new object[] { new[] { "0123456789", "abc" }, "A: 1\r\nB: 2\r\n" }; + yield return new object[] { new[] { new string('Z', 1000) }, "Trailer: value\r\n" }; + + var rng = new Random(42); + var many = Enumerable.Range(0, 20) + .Select(i => new string((char)('a' + i), rng.Next(1, 300))).ToArray(); + yield return new object[] { many, "" }; + } + + // ===================================================================== + // (a) chunk data is handed out as it arrives + // ===================================================================== + + [Theory] + [MemberData(nameof(Bodies))] + public void Span_ByteAtATime_ReassemblesBody(string[] pieces, string trailers) + { + var (wire, body) = Chunked(pieces, trailers); + var f = FeedSpan(new ChunkedBodyStream(), wire, Fixed(1)); + Assert.Equal(body, f.Body.ToArray()); + Assert.Equal(0, f.Leftover); + } + + [Theory] + [MemberData(nameof(Bodies))] + public void Sequence_ByteAtATime_ReassemblesBody(string[] pieces, string trailers) + { + var (wire, body) = Chunked(pieces, trailers); + var f = FeedSequence(new ChunkedBodyStream(), wire, Fixed(1)); + Assert.Equal(body, f.Body.ToArray()); + Assert.Equal(0, f.Leftover); + } + + [Theory] + [MemberData(nameof(Bodies))] + public void Span_RandomSplits_ReassembleBody(string[] pieces, string trailers) + { + var (wire, body) = Chunked(pieces, trailers); + for (int seed = 0; seed < 25; seed++) + { + var f = FeedSpan(new ChunkedBodyStream(), wire, RandomReads(seed, 40)); + Assert.Equal(body, f.Body.ToArray()); + Assert.Equal(0, f.Leftover); + } + } + + [Theory] + [MemberData(nameof(Bodies))] + public void Sequence_RandomSplits_ReassembleBody(string[] pieces, string trailers) + { + var (wire, body) = Chunked(pieces, trailers); + for (int seed = 0; seed < 25; seed++) + { + var f = FeedSequence(new ChunkedBodyStream(), wire, RandomReads(seed, 40), segments: 1 + seed % 5); + Assert.Equal(body, f.Body.ToArray()); + Assert.Equal(0, f.Leftover); + } + } + + [Fact] + public void LargeChunk_BufferingIsBoundedByTheReadWindow() + { + // One 1 MiB chunk read 4 KiB at a time: nothing waits for the whole chunk. + var (wire, body) = Chunked(new[] { new string('Q', 1 << 20) }); + const int read = 4096; + + var span = FeedSpan(new ChunkedBodyStream(), wire, Fixed(read)); + Assert.Equal(body, span.Body.ToArray()); + Assert.True(span.MaxBuffered <= read + 16, $"span buffered {span.MaxBuffered} bytes"); + + var seq = FeedSequence(new ChunkedBodyStream(), wire, Fixed(read)); + Assert.Equal(body, seq.Body.ToArray()); + Assert.True(seq.MaxBuffered <= read + 16, $"sequence buffered {seq.MaxBuffered} bytes"); + } + + [Fact] + public void Span_HugeDeclaredChunk_HandsOutWhatArrived() + { + var stream = new ChunkedBodyStream(); + + var first = B("FFFFFFFF\r\nabc"); + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(first, out var c1, out var off1, out var len1)); + Assert.Equal("abc", Encoding.ASCII.GetString(first.AsSpan(off1, len1))); + Assert.Equal(first.Length, c1); + + var next = B("def"); + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(next, out var c2, out var off2, out var len2)); + Assert.Equal("def", Encoding.ASCII.GetString(next.AsSpan(off2, len2))); + Assert.Equal(next.Length, c2); + } + + [Fact] + public void Sequence_HugeDeclaredChunk_HandsOutWhatArrived() + { + var stream = new ChunkedBodyStream(); + + var first = B("FFFFFFFF\r\nabc"); + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(Segmented(first, 4), out var c1, out var d1)); + Assert.Equal("abc", Encoding.ASCII.GetString(d1.ToArray())); + Assert.Equal(first.Length, c1); + + var next = B("def"); + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(Segmented(next, 3), out var c2, out var d2)); + Assert.Equal("def", Encoding.ASCII.GetString(d2.ToArray())); + Assert.Equal(next.Length, c2); + } + + [Fact] + public void SizeLineWithoutData_NeedsMoreData_NothingConsumed() + { + // never an empty Chunk: with no data yet, the size line is re-presented + var input = B("5\r\n"); + + var span = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, span.TryReadChunk(input, out var c1, out _, out _)); + Assert.Equal(0, c1); + + var seq = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, seq.TryReadChunk(Segmented(input, 3), out var c2, out _)); + Assert.Equal(0, c2); + } + + [Theory] + [InlineData("5\r\nHello")] // the CRLF has not started + [InlineData("5\r\nHello\r")] // half of it is here + public void Span_DataCompleteBeforeItsCrlf_IsHandedOut_ThenCrlfChecked(string first) + { + var stream = new ChunkedBodyStream(); + var input1 = B(first); + + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(input1, out var c1, out var off, out var len)); + Assert.Equal("Hello", Encoding.ASCII.GetString(input1.AsSpan(off, len))); + Assert.Equal(8, c1); // the data, not a partial CRLF + + // the CRLF alone: checked, then wait for the next chunk without consuming + Assert.Equal(ChunkResult.NeedMoreData, stream.TryReadChunk(B("\r"), out var c2, out _, out _)); + Assert.Equal(0, c2); + + var rest = B("\r\n0\r\n\r\n"); + Assert.Equal(ChunkResult.Completed, stream.TryReadChunk(rest, out var c3, out _, out _)); + Assert.Equal(rest.Length, c3); + } + + [Theory] + [InlineData("5\r\nHello")] + [InlineData("5\r\nHello\r")] + public void Sequence_DataCompleteBeforeItsCrlf_IsHandedOut_ThenCrlfChecked(string first) + { + var stream = new ChunkedBodyStream(); + + Assert.Equal(ChunkResult.Chunk, stream.TryReadChunk(Segmented(B(first), 3), out var c1, out var data)); + Assert.Equal("Hello", Encoding.ASCII.GetString(data.ToArray())); + Assert.Equal(8, c1); + + Assert.Equal(ChunkResult.NeedMoreData, stream.TryReadChunk(new ReadOnlySequence(B("\r")), out var c2, out _)); + Assert.Equal(0, c2); + + var rest = B("\r\n0\r\n\r\n"); + Assert.Equal(ChunkResult.Completed, stream.TryReadChunk(Segmented(rest, 2), out var c3, out _)); + Assert.Equal(rest.Length, c3); + } + + [Fact] + public void DataHandedOutEarly_BadCrlfStillRejected() + { + var span = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.Chunk, span.TryReadChunk(B("5\r\nHello"), out _, out _, out _)); + var ex1 = Assert.Throws(() => span.TryReadChunk(B("XY0\r\n\r\n"), out _, out _, out _)); + Assert.Equal("Missing CRLF after chunk data.", ex1.Message); + + var seq = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.Chunk, seq.TryReadChunk(Segmented(B("5\r\nHello"), 2), out _, out _)); + var ex2 = Assert.Throws(() => seq.TryReadChunk(Segmented(B("XY0\r\n\r\n"), 2), out _, out _)); + Assert.Equal("Missing CRLF after chunk data.", ex2.Message); + } + + // ===================================================================== + // (b) the trailer section is capped + // ===================================================================== + + /// + /// A complete chunked body whose trailer section (field lines incl. their CRLFs, not the final + /// empty line) is exactly long. + /// + private static byte[] TrailerBody(int sectionBytes, bool finalLf = true) + { + var sb = new StringBuilder("0\r\n"); + int left = sectionBytes; + while (left > 0) + { + int line = left >= 105 ? 100 : left; // keep the last line >= 5 bytes ("T: " + CRLF) + if (line < 5) throw new ArgumentOutOfRangeException(nameof(sectionBytes)); + sb.Append("T: ").Append('a', line - 5).Append("\r\n"); + left -= line; + } + sb.Append(finalLf ? "\r\n" : "\r"); + return B(sb.ToString()); + } + + private static void AssertTrailerLimit(Action act) + { + var ex = Assert.Throws(act); + Assert.Equal("Chunked trailer section exceeds limit.", ex.Message); + Assert.Equal(431, ex.StatusCode); + } + + private static ChunkResult ReadSpan(ChunkedBodyStream stream, byte[] input) + => stream.TryReadChunk(input, out _, out _, out _); + + private static ChunkResult ReadSequence(ChunkedBodyStream stream, byte[] input, int segments) + => stream.TryReadChunk(Segmented(input, segments), out _, out _); + + [Fact] + public void Default_Is16KiB() + { + Assert.Equal(DefaultTrailerCap, ParserLimits.Default.MaxTotalTrailerBytes); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + [InlineData(DefaultTrailerCap - 1)] + [InlineData(DefaultTrailerCap)] + public void AtOrUnderDefaultCap_Completes(int sectionBytes) + { + var input = TrailerBody(sectionBytes); + + Assert.Equal(ChunkResult.Completed, ReadSpan(new ChunkedBodyStream(), input)); + foreach (var segments in new[] { 1, 3, 17 }) + Assert.Equal(ChunkResult.Completed, ReadSequence(new ChunkedBodyStream(), input, segments)); + + Assert.True(ChunkedBodyValidator.TryValidate(input, out var consumed)); + Assert.Equal(input.Length, consumed); + } + + [Theory] + [InlineData(DefaultTrailerCap + 1)] + [InlineData(DefaultTrailerCap * 4)] + public void OverDefaultCap_IsRejected(int sectionBytes) + { + var input = TrailerBody(sectionBytes); + + AssertTrailerLimit(() => ReadSpan(new ChunkedBodyStream(), input)); + AssertTrailerLimit(() => ReadSpan(default, input)); // default(struct) applies the default cap too + foreach (var segments in new[] { 1, 3, 17 }) + AssertTrailerLimit(() => ReadSequence(new ChunkedBodyStream(), input, segments)); + + AssertTrailerLimit(() => ChunkedBodyValidator.TryValidate(input, out _)); + } + + [Fact] + public void CustomLimit_IsHonoured() + { + var limits = ParserLimits.Default with { MaxTotalTrailerBytes = 64 }; + var atCap = TrailerBody(64); + var overCap = TrailerBody(65); + + Assert.Equal(ChunkResult.Completed, ReadSpan(new ChunkedBodyStream(limits), atCap)); + Assert.Equal(ChunkResult.Completed, ReadSequence(new ChunkedBodyStream(limits), atCap, 3)); + Assert.True(ChunkedBodyValidator.TryValidate(atCap, limits, out _)); + + AssertTrailerLimit(() => ReadSpan(new ChunkedBodyStream(limits), overCap)); + AssertTrailerLimit(() => ReadSequence(new ChunkedBodyStream(limits), overCap, 3)); + AssertTrailerLimit(() => ChunkedBodyValidator.TryValidate(overCap, limits, out _)); + } + + [Fact] + public void ZeroLimit_RefusesAnyTrailerButAllowsNone() + { + var limits = ParserLimits.Default with { MaxTotalTrailerBytes = 0 }; + var none = B("5\r\nHello\r\n0\r\n\r\n"); + var one = B("0\r\nA: b\r\n\r\n"); + + var f = FeedSpan(new ChunkedBodyStream(limits), none, Fixed(none.Length)); + Assert.Equal("Hello", Encoding.ASCII.GetString(f.Body.ToArray())); + Assert.True(ChunkedBodyValidator.TryValidate(none, limits, out _)); + + AssertTrailerLimit(() => ReadSpan(new ChunkedBodyStream(limits), one)); + AssertTrailerLimit(() => ReadSequence(new ChunkedBodyStream(limits), one, 2)); + AssertTrailerLimit(() => ChunkedBodyValidator.TryValidate(one, limits, out _)); + } + + [Fact] + public void IncompleteSectionUnderCap_StillWaits() + { + var input = B("0\r\nT: " + new string('a', 100)); + + var span = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, span.TryReadChunk(input, out var c1, out _, out _)); + Assert.Equal(0, c1); + + var seq = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, seq.TryReadChunk(Segmented(input, 3), out var c2, out _)); + Assert.Equal(0, c2); + + Assert.False(ChunkedBodyValidator.TryValidate(input, out _)); + } + + [Fact] + public void SectionAtCap_FinalCrlfSplit_IsNotRefused() + { + // The trailer lines are exactly at the cap and only the CR of the final empty line has + // arrived: that CR is not trailer content, so this must wait, not be refused. + var partial = TrailerBody(DefaultTrailerCap, finalLf: false); + var full = TrailerBody(DefaultTrailerCap); + + var span = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, ReadSpan(span, partial)); + Assert.Equal(ChunkResult.Completed, ReadSpan(span, full)); + + var seq = new ChunkedBodyStream(); + Assert.Equal(ChunkResult.NeedMoreData, ReadSequence(seq, partial, 3)); + Assert.Equal(ChunkResult.Completed, ReadSequence(seq, full, 3)); + + Assert.False(ChunkedBodyValidator.TryValidate(partial, out _)); + Assert.True(ChunkedBodyValidator.TryValidate(full, out _)); + } + + [Fact] + public void EndlessTrailerLine_IsRejectedNotBuffered() + { + // one trailer line that never ends — no CRLF anywhere + var input = B("0\r\nT: " + new string('a', DefaultTrailerCap + 10)); + + AssertTrailerLimit(() => ReadSpan(new ChunkedBodyStream(), input)); + AssertTrailerLimit(() => ReadSequence(new ChunkedBodyStream(), input, 5)); + AssertTrailerLimit(() => ChunkedBodyValidator.TryValidate(input, out _)); + } + + [Fact] + public void EndlessTrailerLines_AreRejectedOnceThePresentedBytesPassTheCap() + { + // "A: b\r\n" forever, never the final empty line — read 512 bytes at a time, the way a + // PipeReader loop re-presents an incomplete trailer section. + var sb = new StringBuilder("5\r\nHello\r\n0\r\n"); + for (int i = 0; i < 10_000; i++) sb.Append("A: b\r\n"); + var input = B(sb.ToString()); + const int read = 512; + + foreach (var useSequence in new[] { false, true }) + { + var stream = new ChunkedBodyStream(); + int fed = 0; + var ex = Assert.Throws(() => + { + var buffer = new List(); + while (true) + { + int n = Math.Min(read, input.Length - fed); + if (n == 0) return; // fell off the end without a decision: the test fails + buffer.AddRange(input.AsSpan(fed, n).ToArray()); + fed += n; + + while (buffer.Count > 0) + { + ChunkResult r; + int consumed; + if (useSequence) + { + r = stream.TryReadChunk(Segmented(buffer.ToArray(), 3), out long c, out _); + consumed = (int)c; + } + else + { + r = stream.TryReadChunk(buffer.ToArray(), out consumed, out _, out _); + } + if (r != ChunkResult.Chunk) break; + buffer.RemoveRange(0, consumed); + } + } + }); + + Assert.Equal(431, ex.StatusCode); + // refused within one read of crossing the cap, long before the 60 KB stream ends + Assert.InRange(fed, DefaultTrailerCap, DefaultTrailerCap + 13 + read); + } + } + + [Fact] + public void TrailerLimit_IsALimitViolation() + { + var ex = Assert.Throws(() => ReadSpan(new ChunkedBodyStream(), TrailerBody(DefaultTrailerCap + 1))); + Assert.True(ex.IsLimitViolation); + } +} diff --git a/tests/Tests/ChunkedBodyStream.Sequence.cs b/tests/Tests/ChunkedBodyStream.Sequence.cs index e4efba8..f6f0ad1 100644 --- a/tests/Tests/ChunkedBodyStream.Sequence.cs +++ b/tests/Tests/ChunkedBodyStream.Sequence.cs @@ -105,8 +105,9 @@ public void Sequence_NeedMoreData_RollsBack_ThenCompletes() var full = B("5\r\nHello\r\n0\r\n\r\n"); var s = new ChunkedBodyStream(); - // present a prefix mid-chunk → NeedMoreData, state rolled back, nothing consumed - var r1 = s.TryReadChunk(Segmented(full[..7], 3), out var c1, out _); + // present a prefix that ends before any chunk data → NeedMoreData, state rolled back, + // nothing consumed (data that has arrived is handed out; see ChunkedBodyStream.Limits.cs) + var r1 = s.TryReadChunk(Segmented(full[..3], 3), out var c1, out _); Assert.Equal(ChunkResult.NeedMoreData, r1); Assert.Equal(0, c1); diff --git a/tests/Tests/ChunkedBodyStream.Tests.cs b/tests/Tests/ChunkedBodyStream.Tests.cs index 4f1b860..2df65a5 100644 --- a/tests/Tests/ChunkedBodyStream.Tests.cs +++ b/tests/Tests/ChunkedBodyStream.Tests.cs @@ -166,17 +166,22 @@ public void Incremental_ChunkDataAcrossCalls() { var stream = new ChunkedBodyStream(); - // Send size line but only partial data + // Size line plus part of the data: the part that arrived is handed out at once var part1 = B("5\r\nHe"); - var r1 = stream.TryReadChunk(part1, out var c1, out _, out _); - Assert.Equal(ChunkResult.NeedMoreData, r1); - Assert.Equal(0, c1); + var r1 = stream.TryReadChunk(part1, out var c1, out var off1, out var len1); + Assert.Equal(ChunkResult.Chunk, r1); + Assert.Equal("He", Encoding.ASCII.GetString(part1.AsSpan(off1, len1))); + Assert.Equal(part1.Length, c1); - // Now send the full chunk - var part2 = B("5\r\nHello\r\n0\r\n\r\n"); + // The rest of the chunk, its CRLF and the terminal chunk + var part2 = B("llo\r\n0\r\n\r\n"); var r2 = stream.TryReadChunk(part2, out var c2, out var off2, out var len2); Assert.Equal(ChunkResult.Chunk, r2); - Assert.Equal("Hello", Encoding.ASCII.GetString(part2.AsSpan(off2, len2))); + Assert.Equal("llo", Encoding.ASCII.GetString(part2.AsSpan(off2, len2))); + + var r3 = stream.TryReadChunk(part2.AsSpan(c2), out var c3, out _, out _); + Assert.Equal(ChunkResult.Completed, r3); + Assert.Equal(part2.Length - c2, c3); } [Fact] @@ -229,10 +234,10 @@ public void Incremental_MultiChunkByteByByte() // NeedMoreData → continue adding bytes } + // Data is handed out as it arrives — one byte at a time here — and never empty. Assert.True(completed); - Assert.Equal(2, chunks.Count); - Assert.Equal("Foo", chunks[0]); - Assert.Equal("Hi", chunks[1]); + Assert.DoesNotContain("", chunks); + Assert.Equal("FooHi", string.Concat(chunks)); } [Fact] From e877777320aea81b4e019bf52f12ef022382829e Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 20:21:17 +0000 Subject: [PATCH 2/4] Use TheoryData for the chunked-limits test data The Bodies source returned untyped object[] rows (xUnit1042). Same cases. --- tests/Tests/ChunkedBodyStream.Limits.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/Tests/ChunkedBodyStream.Limits.cs b/tests/Tests/ChunkedBodyStream.Limits.cs index e73a7c3..a51fb08 100644 --- a/tests/Tests/ChunkedBodyStream.Limits.cs +++ b/tests/Tests/ChunkedBodyStream.Limits.cs @@ -138,17 +138,20 @@ private static (byte[] wire, byte[] body) Chunked(IEnumerable pieces, st return (B(wire.ToString()), B(body.ToString())); } - public static IEnumerable Bodies() + public static TheoryData Bodies() { - yield return new object[] { new[] { "Hello" }, "" }; - yield return new object[] { new[] { "Hello", " World" }, "" }; - yield return new object[] { new[] { "0123456789", "abc" }, "A: 1\r\nB: 2\r\n" }; - yield return new object[] { new[] { new string('Z', 1000) }, "Trailer: value\r\n" }; - var rng = new Random(42); var many = Enumerable.Range(0, 20) .Select(i => new string((char)('a' + i), rng.Next(1, 300))).ToArray(); - yield return new object[] { many, "" }; + + return new TheoryData + { + { ["Hello"], "" }, + { ["Hello", " World"], "" }, + { ["0123456789", "abc"], "A: 1\r\nB: 2\r\n" }, + { [new string('Z', 1000)], "Trailer: value\r\n" }, + { many, "" }, + }; } // ===================================================================== From 536b07876d4ebc859d7c56770b217cb975febce5 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 22:18:38 +0000 Subject: [PATCH 3/4] Keep the decoder state at 32 bytes; flag pending chunk data The two limit fields grew ChunkedBodyStream from 32 to 40 bytes, and the whole state is copied on every call (the NeedMoreData rollback snapshot). They now sit in the padding after the byte-sized phase, so the state is 32 bytes again, as on main. The ReadOnlySequence overload also tested ReadOnlySequence.IsEmpty on every completed chunk; a local flag set where the data is sliced does the same job without computing a length. Together these were most of a ~7% slowdown in multi-segment chunked decoding against main. Behaviour is unchanged. --- src/Glyph11/Parser/ChunkedBodyStream.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Glyph11/Parser/ChunkedBodyStream.cs b/src/Glyph11/Parser/ChunkedBodyStream.cs index b772521..14bf576 100644 --- a/src/Glyph11/Parser/ChunkedBodyStream.cs +++ b/src/Glyph11/Parser/ChunkedBodyStream.cs @@ -45,13 +45,15 @@ private enum Phase : byte Complete, } + // The limit fields sit in the padding after the byte-sized phase, keeping the state at the + // 32 bytes it had: every call copies it (the NeedMoreData rollback snapshot). private Phase _phase; // starts at ChunkSize + private bool _hasLimits; // false for new ChunkedBodyStream(): ParserLimits.Default applies + private int _maxTrailerBytes; // trailer section cap, when constructed with limits private long _chunkSize; // parsed size for current chunk private long _remaining; // bytes left in ChunkData phase private int _digitCount; // hex digits seen in ChunkSize private int _extensionBytes; // extension bytes (4096 limit) - private int _maxTrailerBytes; // trailer section cap, when constructed with limits - private bool _hasLimits; // false for new ChunkedBodyStream(): ParserLimits.Default applies /// /// Creates a decoder that enforces (today: @@ -367,6 +369,7 @@ public ChunkResult TryReadChunk( var reader = new SequenceReader(input); var snapshot = this; // roll back on NeedMoreData — caller re-presents the bytes ReadOnlySequence pending = default; + bool hasPending = false; // cheaper to test than pending.IsEmpty, once per chunk long trailerStart = 0; // the trailer section is always parsed within one call while (!reader.End) @@ -470,6 +473,7 @@ public ChunkResult TryReadChunk( return ChunkResult.Chunk; } pending = reader.UnreadSequence.Slice(0, _remaining); + hasPending = true; reader.Advance(_remaining); _remaining = 0; _phase = Phase.DataCrlf; @@ -482,7 +486,7 @@ public ChunkResult TryReadChunk( { // The CRLF after the data has not arrived: hand out data read by this call // anyway — the CRLF is checked on the next call. - if (!pending.IsEmpty) + if (hasPending) { bytesConsumed = reader.Consumed; chunkData = pending; @@ -503,7 +507,7 @@ public ChunkResult TryReadChunk( _digitCount = 0; _extensionBytes = 0; - if (!pending.IsEmpty) + if (hasPending) { bytesConsumed = reader.Consumed; chunkData = pending; @@ -561,7 +565,7 @@ public ChunkResult TryReadChunk( // Ran out of input. A chunk whose data is all here but whose CRLF is not is still // handed out (the CRLF is checked on the next call); anything else waits. - if (!pending.IsEmpty) + if (hasPending) { bytesConsumed = reader.Consumed; chunkData = pending; From ec8f1f25f338957bd56d25585d1e16a6862f0549 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 22:41:21 +0000 Subject: [PATCH 4/4] Pin the decoder state at 32 bytes Every TryReadChunk call copies the whole state for the NeedMoreData rollback, so its size is on the hot path. A test now fails if a field grows it past 32 bytes again. --- tests/Tests/ChunkedBodyStream.Limits.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Tests/ChunkedBodyStream.Limits.cs b/tests/Tests/ChunkedBodyStream.Limits.cs index a51fb08..ec5e1b8 100644 --- a/tests/Tests/ChunkedBodyStream.Limits.cs +++ b/tests/Tests/ChunkedBodyStream.Limits.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Runtime.CompilerServices; using System.Text; using Glyph11; using Glyph11.Parser; @@ -529,4 +530,14 @@ public void TrailerLimit_IsALimitViolation() var ex = Assert.Throws(() => ReadSpan(new ChunkedBodyStream(), TrailerBody(DefaultTrailerCap + 1))); Assert.True(ex.IsLimitViolation); } + + // ---- the state every call copies (the NeedMoreData rollback snapshot) stays small ---- + + [Fact] + public void DecoderState_StaysAt32Bytes() + { + // The limit fields sit in the padding after the byte-sized phase. A field that grows the + // struct makes every TryReadChunk call copy more; move it into padding or rethink the snapshot. + Assert.Equal(32, Unsafe.SizeOf()); + } }