diff --git a/Examples/Glyph11.Example/Glyph11.Example.csproj b/Examples/Glyph11.Example/Glyph11.Example.csproj index f42e5ac..386ee74 100644 --- a/Examples/Glyph11.Example/Glyph11.Example.csproj +++ b/Examples/Glyph11.Example/Glyph11.Example.csproj @@ -10,7 +10,7 @@ - + diff --git a/Examples/Glyph11.Example/Program.cs b/Examples/Glyph11.Example/Program.cs index 13ad815..ce9eb8d 100644 --- a/Examples/Glyph11.Example/Program.cs +++ b/Examples/Glyph11.Example/Program.cs @@ -32,7 +32,7 @@ void ContiguousParse() { Console.WriteLine("== 1. Contiguous parse =="); - // The request bytes as one block. TryExtractFullHeaderROM takes a ReadOnlyMemory. + // The request bytes as one block. This TryParseRequestHeader overload takes a ReadOnlyMemory. ReadOnlyMemory input = Encoding.ASCII.GetBytes( "GET /api/users?page=1&sort=asc HTTP/1.1\r\n" + "Host: example.com\r\n" + @@ -43,7 +43,7 @@ void ContiguousParse() // Returns true on a complete, valid header block; false if the buffer is incomplete; // throws HttpParseException if the bytes are a complete but invalid/malicious request. - if (UltraHardenedParser.TryExtractFullHeaderROM(ref input, request, in limits, out int bytesRead)) + if (UltraHardenedParser.TryParseRequestHeader(ref input, request, in limits, out int consumed)) { // Every field is a zero-copy ReadOnlyMemory slice of `input`. Console.WriteLine($" method = {Ascii(request.Method)}"); // GET @@ -66,8 +66,8 @@ void ContiguousParse() Console.WriteLine($" {Ascii(key)} = {Ascii(val)}"); } - // bytesRead follows glyph11's -1 convention: the body begins at bytesRead + 1. - Console.WriteLine($" header block length = {bytesRead + 1} bytes"); + // consumed is the header block's length: a body, if any, begins at input[consumed]. + Console.WriteLine($" header block length = {consumed} bytes"); } Console.WriteLine(); } @@ -90,7 +90,7 @@ void MultiSegmentParse() // Same contract as Example 1, but for a (possibly) multi-segment sequence. It takes the // contiguous fast path automatically when the sequence happens to be single-segment. - if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead)) + if (UltraHardenedParser.TryParseRequestHeader(ref buffer, request, in limits, out _)) Console.WriteLine($" parsed {Ascii(request.Method)} {Ascii(request.Path)} from 3 segments\n"); } @@ -118,7 +118,7 @@ void CustomLimits() request.Clear(); try { - UltraHardenedParser.TryExtractFullHeaderROM(ref tooMany, request, in strict, out _); + UltraHardenedParser.TryParseRequestHeader(ref tooMany, request, in strict, out _); Console.WriteLine(" unexpectedly accepted"); } catch (HttpParseException ex) @@ -175,7 +175,7 @@ void ErrorHandling() // (a) Incomplete — the header block isn't terminated yet. Returns false: read more. ReadOnlyMemory partial = Encoding.ASCII.GetBytes("GET / HTTP/1.1\r\nHost: x\r\n"); request.Clear(); - bool ok = UltraHardenedParser.TryExtractFullHeaderROM(ref partial, request, in limits, out _); + bool ok = UltraHardenedParser.TryParseRequestHeader(ref partial, request, in limits, out _); Console.WriteLine($" incomplete request → returned {ok} (need more bytes)"); // (b) Invalid — a path-traversal attempt. A complete but malicious request → throws. @@ -183,7 +183,7 @@ void ErrorHandling() request.Clear(); try { - UltraHardenedParser.TryExtractFullHeaderROM(ref evil, request, in limits, out _); + UltraHardenedParser.TryParseRequestHeader(ref evil, request, in limits, out _); } catch (HttpParseException ex) { @@ -205,7 +205,7 @@ void ReuseAndDispose() { ReadOnlyMemory req = Encoding.ASCII.GetBytes($"GET {path} HTTP/1.1\r\nHost: x\r\n\r\n"); shared.Clear(); // reset before each parse; reuses the same pooled storage - if (UltraHardenedParser.TryExtractFullHeaderROM(ref req, shared, in limits, out _)) + if (UltraHardenedParser.TryParseRequestHeader(ref req, shared, in limits, out _)) Console.WriteLine($" parsed {Ascii(shared.Path)}"); } diff --git a/README.md b/README.md index c6139a8..7dee01d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ var limits = ParserLimits.Default; ReadOnlySequence buffer = new(Encoding.ASCII.GetBytes( "GET /api/users?page=1 HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n")); -if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead)) +if (UltraHardenedParser.TryParseRequestHeader(ref buffer, request, in limits, out int consumed)) { Console.WriteLine(Encoding.ASCII.GetString(request.Method.Span)); // GET Console.WriteLine(Encoding.ASCII.GetString(request.Path.Span)); // /api/users @@ -50,12 +50,18 @@ if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in li var (name, value) = request.Headers[i]; // zero-copy slices Console.WriteLine($"{Encoding.ASCII.GetString(name.Span)}: {Encoding.ASCII.GetString(value.Span)}"); } - // advance your reader by bytesRead; reuse `request` across calls (the parser clears it first). + // the body (if any) starts at `consumed`: advance your reader by it; reuse `request` across calls (the parser clears it first). } // throws HttpParseException on a protocol/semantic violation; returns false if incomplete. ``` -`TryExtractFullHeaderROM(ref ReadOnlyMemory, …)` is the single-buffer fast path. +The `TryParseRequestHeader(ref ReadOnlyMemory, …)` overload is the single-buffer fast path; +`TryParseResponseHeader` parses responses the same way. + +> **Migrating from `TryExtractFullHeaderROM` / `TryExtractFullHeaderValidated` (and the response and +> `FlexibleParser` equivalents):** those are obsolete since 0.4.0. They report one less than the bytes +> consumed, which is why callers added 1; the `TryParse*Header` methods report the header length itself. +> Switch to the new name and drop the `+ 1`. ## Glyph11.Native — C core via P/Invoke diff --git a/SECURITY.md b/SECURITY.md index 53a1e0b..f5955ad 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ Glyph11's security is delivered by **`UltraHardenedParser`**, which enforces RFC ## Syntactic Protections -These checks are enforced during `TryExtractFullHeaderValidated` / `TryExtractFullHeaderROM`. Violations throw `HttpParseException`. +These checks are enforced during `TryParseRequestHeader` (both the `ReadOnlySequence` and `ReadOnlyMemory` overloads). Violations throw `HttpParseException`. ### Bare LF Rejection @@ -242,9 +242,9 @@ var limits = ParserLimits.Default; // Structural + semantic validation in a single pass. // Throws HttpParseException on any protocol or semantic violation. -if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out var bytesRead)) +if (UltraHardenedParser.TryParseRequestHeader(ref buffer, request, in limits, out var consumed)) { - // Safe to process request — fully validated. + // Safe to process request — fully validated. The body (if any) starts at `consumed`. } ``` diff --git a/site/glyph11.html b/site/glyph11.html index 6bba0be..6608a32 100644 --- a/site/glyph11.html +++ b/site/glyph11.html @@ -72,9 +72,9 @@

Quick start

// true → a complete, valid header block was parsed into `request` // false → the buffer doesn't yet hold a full header block (read more, then retry) // throws HttpParseException → the bytes are a complete but INVALID/malicious request -if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead)) +if (UltraHardenedParser.TryParseRequestHeader(ref buffer, request, in limits, out int consumed)) { - // `request` is now populated. `bytesRead` marks the header block (see "Errors & reuse"). + // `request` is now populated. `consumed` is the header block's length: the body starts there. Console.WriteLine(Encoding.ASCII.GetString(request.Method.Span)); // GET Console.WriteLine(Encoding.ASCII.GetString(request.Path.Span)); // /api/users } @@ -116,18 +116,19 @@

Reading the result

Contiguous fast path

If your request is already in one contiguous buffer (a single byte[] / - ReadOnlyMemory<byte>), use TryExtractFullHeaderROM — it skips the + ReadOnlyMemory<byte>), use the ReadOnlyMemory<byte> overload of + TryParseRequestHeader — it skips the segment-walking machinery and parses in place.

// A request you already have fully buffered as one block.
 ReadOnlyMemory<byte> rom = Encoding.ASCII.GetBytes(
     "GET / HTTP/1.1\r\nHost: x\r\n\r\n");
 
 // Same contract as above (true / false / throws), but for a single contiguous buffer.
-if (UltraHardenedParser.TryExtractFullHeaderROM(ref rom, request, in limits, out int read))
+if (UltraHardenedParser.TryParseRequestHeader(ref rom, request, in limits, out int consumed))
 {
     // request.Method, request.Path, ...
 }
-
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