Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Examples/Glyph11.Example/Glyph11.Example.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<ItemGroup>
<!-- In a real app this is: <PackageReference Include="Glyph11" Version="0.3.6" /> -->
<!-- In a real app this is: <PackageReference Include="Glyph11" Version="0.4.0" /> -->
<ProjectReference Include="..\..\src\Glyph11\Glyph11.csproj" />
</ItemGroup>

Expand Down
18 changes: 9 additions & 9 deletions Examples/Glyph11.Example/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ void ContiguousParse()
{
Console.WriteLine("== 1. Contiguous parse ==");

// The request bytes as one block. TryExtractFullHeaderROM takes a ReadOnlyMemory<byte>.
// The request bytes as one block. This TryParseRequestHeader overload takes a ReadOnlyMemory<byte>.
ReadOnlyMemory<byte> input = Encoding.ASCII.GetBytes(
"GET /api/users?page=1&sort=asc HTTP/1.1\r\n" +
"Host: example.com\r\n" +
Expand All @@ -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<byte> slice of `input`.
Console.WriteLine($" method = {Ascii(request.Method)}"); // GET
Expand All @@ -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();
}
Expand All @@ -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");
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -175,15 +175,15 @@ void ErrorHandling()
// (a) Incomplete — the header block isn't terminated yet. Returns false: read more.
ReadOnlyMemory<byte> 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.
ReadOnlyMemory<byte> evil = Encoding.ASCII.GetBytes("GET /a/../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n");
request.Clear();
try
{
UltraHardenedParser.TryExtractFullHeaderROM(ref evil, request, in limits, out _);
UltraHardenedParser.TryParseRequestHeader(ref evil, request, in limits, out _);
}
catch (HttpParseException ex)
{
Expand All @@ -205,7 +205,7 @@ void ReuseAndDispose()
{
ReadOnlyMemory<byte> 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)}");
}

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ var limits = ParserLimits.Default;
ReadOnlySequence<byte> 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
Expand All @@ -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<byte>, …)` is the single-buffer fast path.
The `TryParseRequestHeader(ref ReadOnlyMemory<byte>, …)` 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

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte>` and `ReadOnlyMemory<byte>` overloads). Violations throw `HttpParseException`.

### Bare LF Rejection

Expand Down Expand Up @@ -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`.
}
```

Expand Down
25 changes: 14 additions & 11 deletions site/glyph11.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ <h2 id="quickstart">Quick start</h2>
// 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 &amp; 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
}</code></pre>
Expand Down Expand Up @@ -116,18 +116,19 @@ <h2 id="result">Reading the result</h2>

<h2 id="contiguous">Contiguous fast path</h2>
<p>If your request is already in one contiguous buffer (a single <code>byte[]</code> /
<code>ReadOnlyMemory&lt;byte&gt;</code>), use <code>TryExtractFullHeaderROM</code> — it skips the
<code>ReadOnlyMemory&lt;byte&gt;</code>), use the <code>ReadOnlyMemory&lt;byte&gt;</code> overload of
<code>TryParseRequestHeader</code> — it skips the
segment-walking machinery and parses in place.</p>
<pre><code class="language-csharp">// A request you already have fully buffered as one block.
ReadOnlyMemory&lt;byte&gt; 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, ...
}</code></pre>
<div class="note"><b>Tip:</b> <code>TryExtractFullHeaderValidated</code> already takes the contiguous fast
<div class="note"><b>Tip:</b> the <code>ReadOnlySequence&lt;byte&gt;</code> overload already takes the contiguous fast
path internally when its sequence is single-segment — so prefer it unless you specifically hold a
<code>ReadOnlyMemory&lt;byte&gt;</code> and want to skip the sequence wrapper.</div>

Expand Down Expand Up @@ -161,11 +162,11 @@ <h2 id="pipereader">Reading from a PipeReader</h2>
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;
Expand Down Expand Up @@ -227,8 +228,10 @@ <h2 id="errors">Errors &amp; reuse</h2>
<li><b><code>HttpParseException</code></b> — the bytes are a complete but invalid request (bad token,
smuggling vector, limit breach, …). Map to HTTP 400 / 431 and close the connection.</li>
</ul>
<div class="warn"><b><code>bytesRead</code> is the header length minus one</b> — a long-standing glyph11
convention. The body begins at <code>bytesRead + 1</code>; advance your reader by that.</div>
<div class="warn"><b>Migrating from <code>TryExtractFullHeaderROM</code> / <code>TryExtractFullHeaderValidated</code>?</b>
Those entry points are obsolete: they report the header length <i>minus one</i>, which is why callers
added 1. <code>TryParseRequestHeader</code> reports the length itself — switch to it and drop the
<code>+ 1</code>. The body begins at <code>consumed</code>.</div>
<p>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; <code>request.Dispose()</code> returns them to the <code>ArrayPool</code> when you're
Expand Down
6 changes: 3 additions & 3 deletions src/Glyph11/Glyph11.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
<Description>Dependency free, low allocation HTTP/1.1 parser for C#.</Description>
<PackageTags>C# HTTP Protocol Parser</PackageTags>

<AssemblyVersion>0.3.7.0</AssemblyVersion>
<FileVersion>0.3.7.0</FileVersion>
<Version>0.3.7</Version>
<AssemblyVersion>0.4.0.0</AssemblyVersion>
<FileVersion>0.4.0.0</FileVersion>
<Version>0.4.0</Version>

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ public static partial class FlexibleParser
/// </summary>
/// <param name="input">Contiguous buffer containing the full HTTP request header.</param>
/// <param name="request">Target to populate with parsed method, path, headers, etc.</param>
/// <param name="bytesReadCount">Number of bytes consumed (header + terminators - 1), or -1 if incomplete.</param>
/// <param name="consumed">
/// On success, the length of the header block including its terminating CRLFCRLF: the body,
/// if any, starts at <c>input[consumed]</c>. 0 when the method returns <c>false</c>.
/// </param>
/// <returns><c>true</c> if a complete header was parsed; <c>false</c> if the <c>\r\n\r\n</c> terminator was not found.</returns>
public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory<byte> input, BinaryRequest request, out int bytesReadCount)
public static bool TryParseRequestHeader(ref ReadOnlyMemory<byte> input, BinaryRequest request, out int consumed)
{
bytesReadCount = -1;
consumed = 0;
var slicedInputSpan = input.Span;

int headerEnd = slicedInputSpan.IndexOf(CrlfCrlf);
Expand Down Expand Up @@ -120,7 +123,7 @@ public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory<byte> i
lineStart += lineLen + 2;
}

bytesReadCount += headerEnd + 4;
consumed = headerEnd + 4;
return true;
}
}
65 changes: 65 additions & 0 deletions src/Glyph11/Parser/FlexibleParser/FlexibleParser.Obsolete.cs
Original file line number Diff line number Diff line change
@@ -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.";

/// <summary>
/// Zero-copy single-segment parse path.
/// All parsed fields are <see cref="ReadOnlyMemory{T}"/> slices into <paramref name="input"/>.
/// </summary>
/// <param name="input">Contiguous buffer containing the full HTTP request header.</param>
/// <param name="request">Target to populate with parsed method, path, headers, etc.</param>
/// <param name="bytesReadCount">
/// One less than the bytes consumed on success (the body starts at <c>bytesReadCount + 1</c>),
/// or -1 if incomplete.
/// </param>
/// <returns><c>true</c> if a complete header was parsed; <c>false</c> if the <c>\r\n\r\n</c> terminator was not found.</returns>
/// <remarks>
/// Obsolete: use <see cref="TryParseRequestHeader(ref ReadOnlyMemory{byte}, BinaryRequest, out int)"/>,
/// which reports the header block length itself — drop the <c>+ 1</c> when migrating.
/// </remarks>
[Obsolete(ObsoleteMessage)]
public static bool TryExtractFullHeaderReadOnlyMemory(ref ReadOnlyMemory<byte> input, BinaryRequest request, out int bytesReadCount)
{
bool complete = TryParseRequestHeader(ref input, request, out int consumed);
bytesReadCount = complete ? consumed - 1 : -1;
return complete;
}

/// <summary>
/// Entry point: tries to extract a complete HTTP/1.1 header block (request line + headers).
/// <para>
/// Single-segment input is dispatched to the zero-copy ROM path.
/// Multi-segment input is checked for completeness (<c>\r\n\r\n</c>), then linearized
/// via <c>ToArray()</c> and parsed through the ROM path.
/// </para>
/// </summary>
/// <param name="input">Input buffer from the network layer.</param>
/// <param name="request">Target to populate with parsed request data.</param>
/// <param name="bytesReadCount">
/// One less than the bytes consumed on success (the body starts at <c>bytesReadCount + 1</c>),
/// or -1 if incomplete.
/// </param>
/// <returns><c>true</c> if a complete header was parsed; <c>false</c> if more data is needed.</returns>
/// <remarks>
/// Obsolete: use <see cref="TryParseRequestHeader(ref ReadOnlySequence{byte}, BinaryRequest, out int)"/>,
/// which reports the header block length itself — drop the <c>+ 1</c> when migrating.
/// </remarks>
[Obsolete(ObsoleteMessage)]
public static bool TryExtractFullHeader(ref ReadOnlySequence<byte> input, BinaryRequest request, out int bytesReadCount)
{
bool complete = TryParseRequestHeader(ref input, request, out int consumed);
bytesReadCount = complete ? consumed - 1 : -1;
return complete;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,29 @@ public static partial class FlexibleParser
/// </summary>
/// <param name="input">Input buffer from the network layer.</param>
/// <param name="request">Target to populate with parsed request data.</param>
/// <param name="bytesReadCount">Bytes consumed on success, or -1 if incomplete.</param>
/// <param name="consumed">
/// On success, the length of the header block including its terminating CRLFCRLF: the body,
/// if any, starts at <c>input.Slice(consumed)</c>. 0 when the method returns <c>false</c>.
/// </param>
/// <returns><c>true</c> if a complete header was parsed; <c>false</c> if more data is needed.</returns>
public static bool TryExtractFullHeader(ref ReadOnlySequence<byte> input, BinaryRequest request, out int bytesReadCount)
public static bool TryParseRequestHeader(ref ReadOnlySequence<byte> input, BinaryRequest request, out int consumed)
{
if (input.IsSingleSegment)
{
ReadOnlyMemory<byte> 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<byte>(input);
if (!reader.TryReadTo(out ReadOnlySequence<byte> _, CrlfCrlf, advancePastDelimiter: true))
{
bytesReadCount = -1;
consumed = 0;
return false;
}

// Linearize: copy all segments into a single contiguous array, then parse via ROM
ReadOnlyMemory<byte> mem = input.ToArray();
return TryExtractFullHeaderReadOnlyMemory(ref mem, request, out bytesReadCount);
return TryParseRequestHeader(ref mem, request, out consumed);
}
}
Loading
Loading