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..14bf576 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 { @@ -37,12 +45,30 @@ 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) + /// + /// 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 . /// Call in a loop after each PipeReader.ReadAsync. @@ -52,11 +78,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 +98,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 +201,7 @@ public ChunkResult TryReadChunk( { // Terminal chunk — move to trailers _phase = Phase.Trailers; + trailerStart = pos; continue; } @@ -180,16 +213,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 +239,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 +265,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 +307,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 +334,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 +350,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 +369,8 @@ 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) { @@ -374,6 +451,7 @@ public ChunkResult TryReadChunk( if (_chunkSize == 0) { _phase = Phase.Trailers; + trailerStart = reader.Consumed; continue; } _remaining = _chunkSize; @@ -385,10 +463,17 @@ 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); + hasPending = true; reader.Advance(_remaining); _remaining = 0; _phase = Phase.DataCrlf; @@ -399,6 +484,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 (hasPending) + { + bytesConsumed = reader.Consumed; + chunkData = pending; + return ChunkResult.Chunk; + } + this = snapshot; return ChunkResult.NeedMoreData; } @@ -413,9 +507,15 @@ public ChunkResult TryReadChunk( _digitCount = 0; _extensionBytes = 0; - bytesConsumed = reader.Consumed; - chunkData = pending; - return ChunkResult.Chunk; + if (hasPending) + { + 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 +535,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 +563,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 (hasPending) + { + 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..ec5e1b8 --- /dev/null +++ b/tests/Tests/ChunkedBodyStream.Limits.cs @@ -0,0 +1,543 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +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 TheoryData Bodies() + { + var rng = new Random(42); + var many = Enumerable.Range(0, 20) + .Select(i => new string((char)('a' + i), rng.Next(1, 300))).ToArray(); + + return new TheoryData + { + { ["Hello"], "" }, + { ["Hello", " World"], "" }, + { ["0123456789", "abc"], "A: 1\r\nB: 2\r\n" }, + { [new string('Z', 1000)], "Trailer: value\r\n" }, + { 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); + } + + // ---- 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()); + } +} 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]