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 @@
The real-world loop: read, try to parse, and if the buffer doesn't yet hold a full header block, @@ -186,7 +187,9 @@
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.
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]