Tip: TryExtractFullHeaderValidated already takes the contiguous fast
+
Tip: the ReadOnlySequence<byte> overload already takes the contiguous fast
path internally when its sequence is single-segment — so prefer it unless you specifically hold a
ReadOnlyMemory<byte> and want to skip the sequence wrapper.
@@ -161,11 +162,11 @@
Reading from a PipeReader
try
{
request.Clear(); // reset before each parse attempt
- if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead))
+ if (UltraHardenedParser.TryParseRequestHeader(ref buffer, request, in limits, out int consumed))
{
- // Got a full, valid request. The body starts right after the header block.
- // `bytesRead` follows glyph11's -1 convention, so advance by bytesRead + 1:
- SequencePosition bodyStart = read.Buffer.GetPosition(bytesRead + 1);
+ // Got a full, valid request. The body starts right after the header block,
+ // which is exactly `consumed` bytes long:
+ SequencePosition bodyStart = read.Buffer.GetPosition(consumed);
// ... dispatch `request`, then read/handle the body from bodyStart ...
reader.AdvanceTo(bodyStart);
return;
@@ -227,8 +228,10 @@
Errors & reuse
HttpParseException — the bytes are a complete but invalid request (bad token,
smuggling vector, limit breach, …). Map to HTTP 400 / 431 and close the connection.
-
bytesRead is the header length minus one — a long-standing glyph11
- convention. The body begins at bytesRead + 1; advance your reader by that.
+
Migrating from TryExtractFullHeaderROM / TryExtractFullHeaderValidated?
+ Those entry points are obsolete: they report the header length minus one, which is why callers
+ added 1. TryParseRequestHeader reports the length itself — switch to it and drop the
+ + 1. The body begins at consumed.
Reuse the request to stay allocation-free across a connection: the parser clears it before
filling it, so nothing from the previous request carries over and the pooled header/query arrays
are kept; request.Dispose() returns them to the ArrayPool when you're
diff --git a/src/Glyph11/Glyph11.csproj b/src/Glyph11/Glyph11.csproj
index 06acc97..968da04 100644
--- a/src/Glyph11/Glyph11.csproj
+++ b/src/Glyph11/Glyph11.csproj
@@ -7,9 +7,9 @@
Dependency free, low allocation HTTP/1.1 parser for C#.
C# HTTP Protocol Parser
- 0.3.7.0
- 0.3.7.0
- 0.3.7
+ 0.4.0.0
+ 0.4.0.0
+ 0.4.0
true
diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.FullHeader.ROM.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.FullHeader.ROM.cs
index 9bb8d05..e6ad394 100644
--- a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.FullHeader.ROM.cs
+++ b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.FullHeader.ROM.cs
@@ -10,11 +10,14 @@ public static partial class FlexibleParser
///
/// Contiguous buffer containing the full HTTP request header.
/// Target to populate with parsed method, path, headers, etc.
- /// Number of bytes consumed (header + terminators - 1), or -1 if incomplete.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input[consumed]. 0 when the method returns false.
+ ///
/// true if a complete header was parsed; false if the \r\n\r\n terminator was not found.
- public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory input, BinaryRequest request, out int bytesReadCount)
+ public static bool TryParseRequestHeader(ref ReadOnlyMemory input, BinaryRequest request, out int consumed)
{
- bytesReadCount = -1;
+ consumed = 0;
var slicedInputSpan = input.Span;
int headerEnd = slicedInputSpan.IndexOf(CrlfCrlf);
@@ -120,7 +123,7 @@ public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory i
lineStart += lineLen + 2;
}
- bytesReadCount += headerEnd + 4;
+ consumed = headerEnd + 4;
return true;
}
}
diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.Obsolete.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.Obsolete.cs
new file mode 100644
index 0000000..ae7d728
--- /dev/null
+++ b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.Obsolete.cs
@@ -0,0 +1,65 @@
+using System.Buffers;
+using Glyph11.Protocol;
+
+namespace Glyph11.Parser.FlexibleParser;
+
+// ---- Legacy entry points ----
+// These report one less than the bytes consumed, the same convention as UltraHardenedParser's legacy
+// entry points (see UltraHardenedParser.Obsolete.cs for why they were renamed rather than corrected in
+// place). Unchanged in behaviour; slated for removal in a later release.
+public static partial class FlexibleParser
+{
+ private const string ObsoleteMessage =
+ "Reports one less than the bytes consumed. Use TryParseRequestHeader, whose 'consumed' is the " +
+ "full header length: the body starts at consumed, not consumed + 1.";
+
+ ///
+ /// Zero-copy single-segment parse path.
+ /// All parsed fields are slices into .
+ ///
+ /// Contiguous buffer containing the full HTTP request header.
+ /// Target to populate with parsed method, path, headers, etc.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed; false if the \r\n\r\n terminator was not found.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteMessage)]
+ public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory input, BinaryRequest request, out int bytesReadCount)
+ {
+ bool complete = TryParseRequestHeader(ref input, request, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+
+ ///
+ /// Entry point: tries to extract a complete HTTP/1.1 header block (request line + headers).
+ ///
+ /// Single-segment input is dispatched to the zero-copy ROM path.
+ /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized
+ /// via ToArray() and parsed through the ROM path.
+ ///
+ ///
+ /// Input buffer from the network layer.
+ /// Target to populate with parsed request data.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed; false if more data is needed.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteMessage)]
+ public static bool TryExtractFullHeader(ref ReadOnlySequence input, BinaryRequest request, out int bytesReadCount)
+ {
+ bool complete = TryParseRequestHeader(ref input, request, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+}
diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
index 5142c9b..c599e14 100644
--- a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
+++ b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
@@ -15,26 +15,29 @@ public static partial class FlexibleParser
///
/// Input buffer from the network layer.
/// Target to populate with parsed request data.
- /// Bytes consumed on success, or -1 if incomplete.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input.Slice(consumed). 0 when the method returns false.
+ ///
/// true if a complete header was parsed; false if more data is needed.
- public static bool TryExtractFullHeader(ref ReadOnlySequence input, BinaryRequest request, out int bytesReadCount)
+ public static bool TryParseRequestHeader(ref ReadOnlySequence input, BinaryRequest request, out int consumed)
{
if (input.IsSingleSegment)
{
ReadOnlyMemory singleMemorySegment = input.First;
- return TryExtractFullHeaderReadOnlyMemory(ref singleMemorySegment, request, out bytesReadCount);
+ return TryParseRequestHeader(ref singleMemorySegment, request, out consumed);
}
// Check for header completeness before allocating
var reader = new SequenceReader(input);
if (!reader.TryReadTo(out ReadOnlySequence _, CrlfCrlf, advancePastDelimiter: true))
{
- bytesReadCount = -1;
+ consumed = 0;
return false;
}
// Linearize: copy all segments into a single contiguous array, then parse via ROM
ReadOnlyMemory mem = input.ToArray();
- return TryExtractFullHeaderReadOnlyMemory(ref mem, request, out bytesReadCount);
+ return TryParseRequestHeader(ref mem, request, out consumed);
}
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs
index f6dceef..286a0fd 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs
@@ -25,12 +25,21 @@ public static partial class UltraHardenedParser
/// structurally or semantically invalid.
///
///
+ /// Contiguous buffer holding the request header (and possibly more).
+ /// Target to populate with parsed request data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input[consumed]. 0 when the method returns false.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
[SkipLocalsInit]
- public static bool TryExtractFullHeaderROM(
+ public static bool TryParseRequestHeader(
ref ReadOnlyMemory input, BinaryRequest request,
- in ParserLimits limits, out int bytesReadCount)
+ in ParserLimits limits, out int consumed)
{
- bytesReadCount = -1;
+ consumed = 0;
var span = input.Span;
int headerEnd = span.IndexOf(ParserConstants.CrlfCrlf);
@@ -300,7 +309,7 @@ public static bool TryExtractFullHeaderROM(
if (hostCount != 1)
throw new HttpParseException("Request must have exactly one Host header.");
- bytesReadCount += totalHeaderBytes;
+ consumed = totalHeaderBytes;
return true;
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs
index d9c8de5..4aa2c99 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs
@@ -27,12 +27,21 @@ public static partial class UltraHardenedParser
/// .
///
///
+ /// Contiguous buffer holding the response header (and possibly more).
+ /// Target to populate with parsed response data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input[consumed]. 0 when the method returns false.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
[SkipLocalsInit]
- public static bool TryExtractFullResponseHeaderROM(
+ public static bool TryParseResponseHeader(
ref ReadOnlyMemory input, BinaryResponse response,
- in ParserLimits limits, out int bytesReadCount)
+ in ParserLimits limits, out int consumed)
{
- bytesReadCount = -1;
+ consumed = 0;
var span = input.Span;
int headerEnd = span.IndexOf(ParserConstants.CrlfCrlf);
@@ -244,7 +253,7 @@ public static bool TryExtractFullResponseHeaderROM(
if (hasTE && hasCL)
throw new HttpParseException("Both Transfer-Encoding and Content-Length are present.");
- bytesReadCount += totalHeaderBytes;
+ consumed = totalHeaderBytes;
return true;
}
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.Obsolete.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.Obsolete.cs
new file mode 100644
index 0000000..6accd70
--- /dev/null
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.Obsolete.cs
@@ -0,0 +1,148 @@
+using System.Buffers;
+using Glyph11.Protocol;
+
+namespace Glyph11.Parser.UltraHardened;
+
+// ---- Legacy entry points ----
+// These report one less than the bytes consumed, a convention every caller compensated for with
+// "bytesRead + 1". Correcting it in place would have broken those callers silently: the first body
+// byte would be read as header and a Content-Length body would arrive one byte short. So the
+// correctly-specified methods got new names (TryParseRequestHeader / TryParseResponseHeader) and these
+// stay, unchanged in behaviour, with an [Obsolete] warning that makes the migration visible at
+// compile time. Slated for removal in a later release.
+public static partial class UltraHardenedParser
+{
+ private const string ObsoleteRequestMessage =
+ "Reports one less than the bytes consumed. Use TryParseRequestHeader, whose 'consumed' is the " +
+ "full header length: the body starts at consumed, not consumed + 1.";
+
+ private const string ObsoleteResponseMessage =
+ "Reports one less than the bytes consumed. Use TryParseResponseHeader, whose 'consumed' is the " +
+ "full header length: the body starts at consumed, not consumed + 1.";
+
+ ///
+ /// Combined parse + semantic validation — single-segment hot path.
+ /// Performs a full structural parse plus every semantic check (request smuggling,
+ /// path traversal, Host rules, ...), fused into one pass over headers and one pass
+ /// over the path for better cache locality and throughput.
+ ///
+ /// Returns false if incomplete; throws if
+ /// structurally or semantically invalid.
+ ///
+ ///
+ /// Contiguous buffer holding the request header (and possibly more).
+ /// Target to populate with parsed request data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteRequestMessage)]
+ public static bool TryExtractFullHeaderROM(
+ ref ReadOnlyMemory input, BinaryRequest request,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ bool complete = TryParseRequestHeader(ref input, request, in limits, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+
+ ///
+ /// Entry point: combined parse + semantic validation with full security checks.
+ ///
+ /// Single-segment input is dispatched to the zero-copy validated ROM path.
+ /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized
+ /// via ToArray() and parsed through the validated ROM path.
+ ///
+ ///
+ /// Input buffer from the network layer.
+ /// Target to populate with parsed request data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteRequestMessage)]
+ public static bool TryExtractFullHeaderValidated(
+ ref ReadOnlySequence input, BinaryRequest request,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ bool complete = TryParseRequestHeader(ref input, request, in limits, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+
+ ///
+ /// Combined parse + semantic validation of a RESPONSE header — single-segment hot path.
+ ///
+ /// Returns false if incomplete; throws if
+ /// structurally or semantically invalid.
+ ///
+ ///
+ /// Contiguous buffer holding the response header (and possibly more).
+ /// Target to populate with parsed response data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteResponseMessage)]
+ public static bool TryExtractFullResponseHeaderROM(
+ ref ReadOnlyMemory input, BinaryResponse response,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ bool complete = TryParseResponseHeader(ref input, response, in limits, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+
+ ///
+ /// Entry point for RESPONSE headers: combined parse + semantic validation with full security
+ /// checks.
+ ///
+ /// Single-segment input is dispatched to the zero-copy validated ROM path.
+ /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized
+ /// via ToArray() and parsed through the validated ROM path.
+ ///
+ ///
+ /// Input buffer from the network layer.
+ /// Target to populate with parsed response data.
+ /// Resource limits to enforce during parsing.
+ ///
+ /// One less than the bytes consumed on success (the body starts at bytesReadCount + 1),
+ /// or -1 if incomplete.
+ ///
+ /// true if a complete header was parsed and validated; false if more data is needed.
+ /// Thrown on any protocol or semantic violation.
+ ///
+ /// Obsolete: use ,
+ /// which reports the header block length itself — drop the + 1 when migrating.
+ ///
+ [Obsolete(ObsoleteResponseMessage)]
+ public static bool TryExtractFullResponseHeaderValidated(
+ ref ReadOnlySequence input, BinaryResponse response,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ bool complete = TryParseResponseHeader(ref input, response, in limits, out int consumed);
+ bytesReadCount = complete ? consumed - 1 : -1;
+ return complete;
+ }
+}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
index 68801d8..736f59c 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
@@ -16,29 +16,32 @@ public static partial class UltraHardenedParser
/// Input buffer from the network layer.
/// Target to populate with parsed request data.
/// Resource limits to enforce during parsing.
- /// Bytes consumed on success, or -1 if incomplete.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input.Slice(consumed). 0 when the method returns false.
+ ///
/// true if a complete header was parsed and validated; false if more data is needed.
/// Thrown on any protocol or semantic violation.
- public static bool TryExtractFullHeaderValidated(
+ public static bool TryParseRequestHeader(
ref ReadOnlySequence input, BinaryRequest request,
- in ParserLimits limits, out int bytesReadCount)
+ in ParserLimits limits, out int consumed)
{
if (input.IsSingleSegment)
{
ReadOnlyMemory singleMemorySegment = input.First;
- return TryExtractFullHeaderROM(ref singleMemorySegment, request, in limits, out bytesReadCount);
+ return TryParseRequestHeader(ref singleMemorySegment, request, in limits, out consumed);
}
// Check for header completeness before allocating
var reader = new SequenceReader(input);
if (!reader.TryReadTo(out ReadOnlySequence _, ParserConstants.CrlfCrlf, advancePastDelimiter: true))
{
- bytesReadCount = -1;
+ consumed = 0;
return false;
}
// Linearize: copy all segments into a single contiguous array, then parse via ROM
ReadOnlyMemory mem = input.ToArray();
- return TryExtractFullHeaderROM(ref mem, request, in limits, out bytesReadCount);
+ return TryParseRequestHeader(ref mem, request, in limits, out consumed);
}
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
index 3ead9ce..29d66f2 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
@@ -17,34 +17,38 @@ public static partial class UltraHardenedParser
/// Input buffer from the network layer.
/// Target to populate with parsed response data.
/// Resource limits to enforce during parsing.
- /// Bytes consumed on success, or -1 if incomplete.
+ ///
+ /// On success, the length of the header block including its terminating CRLFCRLF: the body,
+ /// if any, starts at input.Slice(consumed). 0 when the method returns false.
+ ///
/// true if a complete header was parsed and validated; false if more data is needed.
/// Thrown on any protocol or semantic violation.
///
- /// A client that already holds the response in one contiguous buffer should call
- /// directly and skip the linearizing copy — a
- /// response header spanning segments is the common case on a socket, not the rare one.
+ /// A client that already holds the response in one contiguous buffer should call the
+ ///
+ /// overload directly and skip the linearizing copy — a response header spanning segments is
+ /// the common case on a socket, not the rare one.
///
- public static bool TryExtractFullResponseHeaderValidated(
+ public static bool TryParseResponseHeader(
ref ReadOnlySequence input, BinaryResponse response,
- in ParserLimits limits, out int bytesReadCount)
+ in ParserLimits limits, out int consumed)
{
if (input.IsSingleSegment)
{
ReadOnlyMemory singleMemorySegment = input.First;
- return TryExtractFullResponseHeaderROM(ref singleMemorySegment, response, in limits, out bytesReadCount);
+ return TryParseResponseHeader(ref singleMemorySegment, response, in limits, out consumed);
}
// Check for header completeness before allocating
var reader = new SequenceReader(input);
if (!reader.TryReadTo(out ReadOnlySequence _, ParserConstants.CrlfCrlf, advancePastDelimiter: true))
{
- bytesReadCount = -1;
+ consumed = 0;
return false;
}
// Linearize: copy all segments into a single contiguous array, then parse via ROM
ReadOnlyMemory mem = input.ToArray();
- return TryExtractFullResponseHeaderROM(ref mem, response, in limits, out bytesReadCount);
+ return TryParseResponseHeader(ref mem, response, in limits, out consumed);
}
}
diff --git a/tests/Tests/ConsumedCount.Tests.cs b/tests/Tests/ConsumedCount.Tests.cs
new file mode 100644
index 0000000..7e64bb4
--- /dev/null
+++ b/tests/Tests/ConsumedCount.Tests.cs
@@ -0,0 +1,355 @@
+using System.Buffers;
+using System.Text;
+using Glyph11.Parser;
+using Glyph11.Parser.FlexibleParser;
+using Glyph11.Parser.UltraHardened;
+using Glyph11.Protocol;
+using Glyph11.Utils;
+
+namespace Tests;
+
+///
+/// Pins what consumed means on the TryParse*Header entry points: the length of the header
+/// block including its terminating CRLFCRLF, so whatever follows (a body, or the next pipelined
+/// message) starts exactly at consumed. Also pins the obsolete TryExtract* entry points to
+/// their legacy value, consumed - 1 (and -1 when incomplete), so callers that still add 1 keep
+/// framing correctly until they migrate.
+///
+public class ConsumedCountTests
+{
+ /// How the input reaches the parser.
+ public enum Shape
+ {
+ /// A contiguous .
+ Memory,
+ /// A single-segment .
+ SingleSegment,
+ /// Three segments.
+ ThreeSegments,
+ /// One byte per segment: every boundary, including inside the CRLFCRLF.
+ ByteSegments,
+ }
+
+ private static readonly Shape[] Shapes =
+ [Shape.Memory, Shape.SingleSegment, Shape.ThreeSegments, Shape.ByteSegments];
+
+ // (header block, what follows it in the same buffer)
+ private static readonly (string Head, string Tail)[] RequestCorpus =
+ [
+ ("GET / HTTP/1.1\r\nHost: x\r\n\r\n", ""),
+ ("GET /api/users?a=1&b=2 HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n", ""),
+ ("POST /submit HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\n", "hello"),
+ ("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n", "5\r\nhello\r\n0\r\n\r\n"),
+ ("OPTIONS * HTTP/1.1\r\nHost: x\r\n\r\n", ""),
+ ("GET / HTTP/1.0\r\nHost: x\r\n\r\n", ""),
+ // Pipelined: the next request must start exactly at consumed.
+ ("GET /a HTTP/1.1\r\nHost: x\r\n\r\n", "GET /b HTTP/1.1\r\nHost: x\r\n\r\n"),
+ ];
+
+ private static readonly (string Head, string Tail)[] ResponseCorpus =
+ [
+ ("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n", "hi"),
+ ("HTTP/1.1 204 No Content\r\n\r\n", ""),
+ ("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\n", "not found"),
+ ("HTTP/1.0 200 OK\r\n\r\n", "body until close"),
+ ("HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n", "2\r\nhi\r\n0\r\n\r\n"),
+ // Pipelined: the next response must start exactly at consumed.
+ ("HTTP/1.1 304 Not Modified\r\n\r\n", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"),
+ ];
+
+ private static readonly string[] IncompleteRequests =
+ [
+ "",
+ "GET / HTTP/1.1\r\nHost: x\r\n",
+ "GET / HTTP/1.1\r\nHost: x\r\n\r",
+ ];
+
+ private static readonly string[] IncompleteResponses =
+ [
+ "",
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n",
+ "HTTP/1.1 200 OK\r\n\r",
+ ];
+
+ public static TheoryData Requests() => InEveryShape(RequestCorpus);
+
+ public static TheoryData Responses() => InEveryShape(ResponseCorpus);
+
+ public static TheoryData PartialRequests() => InEveryShape(IncompleteRequests);
+
+ public static TheoryData PartialResponses() => InEveryShape(IncompleteResponses);
+
+ private static TheoryData InEveryShape((string Head, string Tail)[] corpus)
+ {
+ var data = new TheoryData();
+ foreach (var (head, tail) in corpus)
+ foreach (var shape in Shapes)
+ data.Add(head, tail, shape);
+ return data;
+ }
+
+ private static TheoryData InEveryShape(string[] inputs)
+ {
+ var data = new TheoryData();
+ foreach (var input in inputs)
+ foreach (var shape in Shapes)
+ data.Add(input, shape);
+ return data;
+ }
+
+ // ================================================================
+ // consumed is the header block length; what follows starts there
+ // ================================================================
+
+ [Theory]
+ [MemberData(nameof(Requests))]
+ public void UltraHardened_Request_ConsumedIsHeaderLength(string head, string rest, Shape shape)
+ {
+ bool ok = ParseRequest(Bytes(head + rest), shape, out int consumed, out string after);
+
+ Assert.True(ok);
+ Assert.Equal(head.Length, consumed);
+ Assert.Equal(rest, after);
+ }
+
+ [Theory]
+ [MemberData(nameof(Responses))]
+ public void UltraHardened_Response_ConsumedIsHeaderLength(string head, string rest, Shape shape)
+ {
+ bool ok = ParseResponse(Bytes(head + rest), shape, out int consumed, out string after);
+
+ Assert.True(ok);
+ Assert.Equal(head.Length, consumed);
+ Assert.Equal(rest, after);
+ }
+
+ [Theory]
+ [MemberData(nameof(Requests))]
+ public void Flexible_Request_ConsumedIsHeaderLength(string head, string rest, Shape shape)
+ {
+ bool ok = ParseFlexible(Bytes(head + rest), shape, out int consumed, out string after);
+
+ Assert.True(ok);
+ Assert.Equal(head.Length, consumed);
+ Assert.Equal(rest, after);
+ }
+
+ [Fact]
+ public void UltraHardened_Request_BodyIsReadableAtConsumed()
+ {
+ // The case the off-by-one used to corrupt: a Content-Length body read from consumed must be
+ // the whole body, first byte included.
+ ReadOnlyMemory input = Bytes("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello");
+ using var request = new BinaryRequest();
+
+ Assert.True(UltraHardenedParser.TryParseRequestHeader(ref input, request, ParserLimits.Default, out int consumed));
+ AssertAscii.Equal("hello", input.Slice(consumed, 5));
+ }
+
+ // ================================================================
+ // Incomplete: false, consumed 0
+ // ================================================================
+
+ [Theory]
+ [MemberData(nameof(PartialRequests))]
+ public void UltraHardened_Request_Incomplete_ConsumedIsZero(string raw, Shape shape)
+ {
+ Assert.False(ParseRequest(Bytes(raw), shape, out int consumed, out _));
+ Assert.Equal(0, consumed);
+ }
+
+ [Theory]
+ [MemberData(nameof(PartialResponses))]
+ public void UltraHardened_Response_Incomplete_ConsumedIsZero(string raw, Shape shape)
+ {
+ Assert.False(ParseResponse(Bytes(raw), shape, out int consumed, out _));
+ Assert.Equal(0, consumed);
+ }
+
+ [Theory]
+ [MemberData(nameof(PartialRequests))]
+ public void Flexible_Request_Incomplete_ConsumedIsZero(string raw, Shape shape)
+ {
+ Assert.False(ParseFlexible(Bytes(raw), shape, out int consumed, out _));
+ Assert.Equal(0, consumed);
+ }
+
+ // ================================================================
+ // Obsolete entry points keep their legacy value: consumed - 1, or -1
+ // ================================================================
+
+ [Theory]
+ [MemberData(nameof(Requests))]
+ public void Legacy_UltraHardened_Request_IsConsumedMinusOne(string head, string rest, Shape shape)
+ {
+ byte[] bytes = Bytes(head + rest);
+ Assert.True(ParseRequest(bytes, shape, out int consumed, out _));
+ Assert.True(LegacyRequest(bytes, shape, out int bytesReadCount));
+ Assert.Equal(consumed - 1, bytesReadCount);
+ }
+
+ [Theory]
+ [MemberData(nameof(Responses))]
+ public void Legacy_UltraHardened_Response_IsConsumedMinusOne(string head, string rest, Shape shape)
+ {
+ byte[] bytes = Bytes(head + rest);
+ Assert.True(ParseResponse(bytes, shape, out int consumed, out _));
+ Assert.True(LegacyResponse(bytes, shape, out int bytesReadCount));
+ Assert.Equal(consumed - 1, bytesReadCount);
+ }
+
+ [Theory]
+ [MemberData(nameof(Requests))]
+ public void Legacy_Flexible_Request_IsConsumedMinusOne(string head, string rest, Shape shape)
+ {
+ byte[] bytes = Bytes(head + rest);
+ Assert.True(ParseFlexible(bytes, shape, out int consumed, out _));
+ Assert.True(LegacyFlexible(bytes, shape, out int bytesReadCount));
+ Assert.Equal(consumed - 1, bytesReadCount);
+ }
+
+ [Theory]
+ [MemberData(nameof(PartialRequests))]
+ public void Legacy_Request_Incomplete_IsMinusOne(string raw, Shape shape)
+ {
+ Assert.False(LegacyRequest(Bytes(raw), shape, out int bytesReadCount));
+ Assert.Equal(-1, bytesReadCount);
+
+ Assert.False(LegacyFlexible(Bytes(raw), shape, out int flexibleCount));
+ Assert.Equal(-1, flexibleCount);
+ }
+
+ [Theory]
+ [MemberData(nameof(PartialResponses))]
+ public void Legacy_Response_Incomplete_IsMinusOne(string raw, Shape shape)
+ {
+ Assert.False(LegacyResponse(Bytes(raw), shape, out int bytesReadCount));
+ Assert.Equal(-1, bytesReadCount);
+ }
+
+ // ================================================================
+ // Helpers
+ // ================================================================
+
+ private static byte[] Bytes(string s) => Encoding.ASCII.GetBytes(s);
+
+ private static string Ascii(ReadOnlySpan b) => Encoding.ASCII.GetString(b);
+
+ private static ReadOnlySequence ToSequence(byte[] data, Shape shape) => shape switch
+ {
+ Shape.SingleSegment => new ReadOnlySequence(data),
+ Shape.ThreeSegments => Split(data, Math.Max(1, (data.Length + 2) / 3)),
+ Shape.ByteSegments => Split(data, 1),
+ _ => throw new ArgumentOutOfRangeException(nameof(shape)),
+ };
+
+ private static ReadOnlySequence Split(byte[] data, int size)
+ {
+ if (data.Length == 0)
+ return ReadOnlySequence.Empty;
+
+ var first = new BufferSegment(data.AsMemory(0, Math.Min(size, data.Length)));
+ var last = first;
+ for (int i = size; i < data.Length; i += size)
+ last = last.Append(data.AsMemory(i, Math.Min(size, data.Length - i)));
+
+ return new ReadOnlySequence(first, 0, last, last.Memory.Length);
+ }
+
+ private static bool ParseRequest(byte[] bytes, Shape shape, out int consumed, out string after)
+ {
+ using var request = new BinaryRequest();
+ bool ok;
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ ok = UltraHardenedParser.TryParseRequestHeader(ref rom, request, ParserLimits.Default, out consumed);
+ after = ok ? Ascii(rom.Span[consumed..]) : "";
+ return ok;
+ }
+
+ var seq = ToSequence(bytes, shape);
+ ok = UltraHardenedParser.TryParseRequestHeader(ref seq, request, ParserLimits.Default, out consumed);
+ after = ok ? Ascii(seq.Slice(consumed).ToArray()) : "";
+ return ok;
+ }
+
+ private static bool ParseResponse(byte[] bytes, Shape shape, out int consumed, out string after)
+ {
+ using var response = new BinaryResponse();
+ bool ok;
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ ok = UltraHardenedParser.TryParseResponseHeader(ref rom, response, ParserLimits.Default, out consumed);
+ after = ok ? Ascii(rom.Span[consumed..]) : "";
+ return ok;
+ }
+
+ var seq = ToSequence(bytes, shape);
+ ok = UltraHardenedParser.TryParseResponseHeader(ref seq, response, ParserLimits.Default, out consumed);
+ after = ok ? Ascii(seq.Slice(consumed).ToArray()) : "";
+ return ok;
+ }
+
+ private static bool ParseFlexible(byte[] bytes, Shape shape, out int consumed, out string after)
+ {
+ using var request = new BinaryRequest();
+ bool ok;
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ ok = FlexibleParser.TryParseRequestHeader(ref rom, request, out consumed);
+ after = ok ? Ascii(rom.Span[consumed..]) : "";
+ return ok;
+ }
+
+ var seq = ToSequence(bytes, shape);
+ ok = FlexibleParser.TryParseRequestHeader(ref seq, request, out consumed);
+ after = ok ? Ascii(seq.Slice(consumed).ToArray()) : "";
+ return ok;
+ }
+
+#pragma warning disable CS0618 // the obsolete entry points are exactly what these helpers pin
+
+ private static bool LegacyRequest(byte[] bytes, Shape shape, out int bytesReadCount)
+ {
+ using var request = new BinaryRequest();
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ return UltraHardenedParser.TryExtractFullHeaderROM(ref rom, request, ParserLimits.Default, out bytesReadCount);
+ }
+
+ var seq = ToSequence(bytes, shape);
+ return UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, request, ParserLimits.Default, out bytesReadCount);
+ }
+
+ private static bool LegacyResponse(byte[] bytes, Shape shape, out int bytesReadCount)
+ {
+ using var response = new BinaryResponse();
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ return UltraHardenedParser.TryExtractFullResponseHeaderROM(ref rom, response, ParserLimits.Default, out bytesReadCount);
+ }
+
+ var seq = ToSequence(bytes, shape);
+ return UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, response, ParserLimits.Default, out bytesReadCount);
+ }
+
+ private static bool LegacyFlexible(byte[] bytes, Shape shape, out int bytesReadCount)
+ {
+ using var request = new BinaryRequest();
+ if (shape == Shape.Memory)
+ {
+ ReadOnlyMemory rom = bytes;
+ return FlexibleParser.TryExtractFullHeaderReadOnlyMemory(ref rom, request, out bytesReadCount);
+ }
+
+ var seq = ToSequence(bytes, shape);
+ return FlexibleParser.TryExtractFullHeader(ref seq, request, out bytesReadCount);
+ }
+
+#pragma warning restore CS0618
+}
diff --git a/tests/Tests/Tests.csproj b/tests/Tests/Tests.csproj
index 9268718..cafb485 100644
--- a/tests/Tests/Tests.csproj
+++ b/tests/Tests/Tests.csproj
@@ -3,6 +3,9 @@
net8.0;net9.0;net10.0
false
+
+ $(NoWarn);CS0618