diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
index c599e14..18eb1ce 100644
--- a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
+++ b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs
@@ -9,8 +9,8 @@ public static partial class FlexibleParser
/// 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.
+ /// Multi-segment input is checked for completeness (\r\n\r\n), then its header block
+ /// alone is linearized (a pipelined body is never copied) and parsed through the ROM path.
///
///
/// Input buffer from the network layer.
@@ -36,8 +36,9 @@ public static bool TryParseRequestHeader(ref ReadOnlySequence input, Binar
return false;
}
- // Linearize: copy all segments into a single contiguous array, then parse via ROM
- ReadOnlyMemory mem = input.ToArray();
+ // Linearize the header block only — never the pipelined body behind it.
+ // reader.Consumed is the block's length, CRLFCRLF included.
+ ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray();
return TryParseRequestHeader(ref mem, request, out consumed);
}
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
index ed6fe80..e89a868 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs
@@ -9,8 +9,9 @@ public static partial class UltraHardenedParser
/// 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.
+ /// Multi-segment input is checked for completeness (\r\n\r\n) and against
+ /// , then its header block alone is linearized
+ /// (a pipelined body is never copied) and parsed through the validated ROM path.
///
///
/// Input buffer from the network layer.
@@ -40,8 +41,12 @@ public static bool TryParseRequestHeader(
return false;
}
- // Linearize: copy all segments into a single contiguous array, then parse via ROM
- ReadOnlyMemory mem = input.ToArray();
+ // Linearize the header block only — never the pipelined body behind it — and only once it
+ // is known to fit: reader.Consumed is the block's length, CRLFCRLF included.
+ if (reader.Consumed > limits.MaxTotalHeaderBytes)
+ throw new HttpParseException("Total header size exceeds limit.", statusCode: 431);
+
+ ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray();
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 9416cec..6bd1ea8 100644
--- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
@@ -10,8 +10,9 @@ public static partial class UltraHardenedParser
/// 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.
+ /// Multi-segment input is checked for completeness (\r\n\r\n) and against
+ /// , then its header block alone is linearized
+ /// (the body is never copied) and parsed through the validated ROM path.
///
///
/// Input buffer from the network layer.
@@ -47,8 +48,12 @@ public static bool TryParseResponseHeader(
return false;
}
- // Linearize: copy all segments into a single contiguous array, then parse via ROM
- ReadOnlyMemory mem = input.ToArray();
+ // Linearize the header block only — never the body behind it — and only once it is known
+ // to fit: reader.Consumed is the block's length, CRLFCRLF included.
+ if (reader.Consumed > limits.MaxTotalHeaderBytes)
+ throw new HttpParseException("Total header size exceeds limit.", statusCode: 431);
+
+ ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray();
return TryParseResponseHeader(ref mem, response, in limits, out consumed);
}
}
diff --git a/tests/Tests/MultiSegment.HeaderCopy.cs b/tests/Tests/MultiSegment.HeaderCopy.cs
new file mode 100644
index 0000000..a434670
--- /dev/null
+++ b/tests/Tests/MultiSegment.HeaderCopy.cs
@@ -0,0 +1,252 @@
+using System.Buffers;
+using System.Runtime.InteropServices;
+using System.Text;
+using Glyph11;
+using Glyph11.Parser;
+using Glyph11.Parser.FlexibleParser;
+using Glyph11.Parser.UltraHardened;
+using Glyph11.Protocol;
+using Glyph11.Utils;
+
+namespace Tests;
+
+///
+/// Multi-segment input is linearized before parsing. Only the header block may be copied — a
+/// pipelined body behind it never — and the header limit is checked before anything is copied
+/// (issue #65).
+///
+public class MultiSegmentHeaderCopyTests : IDisposable
+{
+ private const int BodySize = 8 * 1024 * 1024;
+
+ private const string RequestHeader =
+ "POST /upload?x=1 HTTP/1.1\r\nHost: example.com\r\nContent-Length: 8388608\r\n\r\n";
+
+ private const string ResponseHeader =
+ "HTTP/1.1 200 OK\r\nContent-Length: 8388608\r\n\r\n";
+
+ private readonly BinaryRequest _request = new();
+ private readonly BinaryResponse _response = new();
+
+ public void Dispose()
+ {
+ _request.Dispose();
+ _response.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ // ================================================================
+ // UltraHardenedParser — requests
+ // ================================================================
+
+ [Fact]
+ public void Request_PipelinedBody_IsNotCopied()
+ {
+ var limits = ParserLimits.Default;
+ var seq = HeaderThenBody(RequestHeader, BodySize);
+
+ WarmUp(() =>
+ {
+ var warm = HeaderThenBody(RequestHeader, 16);
+ UltraHardenedParser.TryExtractFullHeaderValidated(ref warm, _request, in limits, out _);
+ });
+ _request.Clear();
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ bool ok = UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(ok);
+ Assert.Equal(RequestHeader.Length - 1, bytesRead);
+ AssertAscii.Equal("POST", _request.Method);
+ AssertAscii.Equal("/upload", _request.Path);
+ Assert.Equal(2, _request.Headers.Count);
+ AssertAscii.Equal("Content-Length", _request.Headers[1].Key);
+
+ Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method));
+ Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte pipelined body");
+ }
+
+ [Theory]
+ [InlineData(1)] // terminator split as "\r" | "\n\r\n"
+ [InlineData(2)] // "\r\n" | "\r\n"
+ [InlineData(3)] // "\r\n\r" | "\n"
+ public void Request_TerminatorSplitAcrossSegments_CopiesExactlyTheHeaderBlock(int splitInsideTerminator)
+ {
+ var limits = ParserLimits.Default;
+ int cut = RequestHeader.Length - 4 + splitInsideTerminator;
+ var bytes = Encoding.ASCII.GetBytes(RequestHeader + "BODYBODYBODY");
+ var seq = Segments(bytes[..cut], bytes[cut..]);
+
+ Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead));
+ Assert.Equal(RequestHeader.Length - 1, bytesRead);
+ Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Path));
+ }
+
+ [Fact]
+ public void Request_BodyStartingInTheTerminatorsSegment_IsNotCopied()
+ {
+ var limits = ParserLimits.Default;
+ var head = Encoding.ASCII.GetBytes(RequestHeader);
+ var seq = Segments(head[..20], [.. head[20..], .. "partial body"u8.ToArray()], new byte[4096]);
+
+ Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead));
+ Assert.Equal(RequestHeader.Length - 1, bytesRead);
+ Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method));
+ }
+
+ [Fact]
+ public void Request_HeaderOverLimit_Throws431_BeforeCopying()
+ {
+ var limits = ParserLimits.Default with { MaxTotalHeaderBytes = RequestHeader.Length - 1 };
+ var seq = HeaderThenBody(RequestHeader, BodySize);
+
+ WarmUp(() =>
+ {
+ var warm = HeaderThenBody(RequestHeader, 16);
+ try { UltraHardenedParser.TryExtractFullHeaderValidated(ref warm, _request, in limits, out _); }
+ catch (HttpParseException) { }
+ });
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ var ex = Assert.Throws(
+ () => UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out _));
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(431, ex.StatusCode);
+ Assert.True(allocated < 1024 * 1024, $"allocated {allocated} bytes before rejecting");
+ }
+
+ [Fact]
+ public void Request_HeaderExactlyAtLimit_Parses()
+ {
+ var limits = ParserLimits.Default with { MaxTotalHeaderBytes = RequestHeader.Length };
+ var seq = HeaderThenBody(RequestHeader, 1024);
+
+ Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead));
+ Assert.Equal(RequestHeader.Length - 1, bytesRead);
+ }
+
+ // ================================================================
+ // UltraHardenedParser — responses
+ // ================================================================
+
+ [Fact]
+ public void Response_Body_IsNotCopied()
+ {
+ var limits = ParserLimits.Default;
+ var seq = HeaderThenBody(ResponseHeader, BodySize);
+
+ WarmUp(() =>
+ {
+ var warm = HeaderThenBody(ResponseHeader, 16);
+ UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref warm, _response, in limits, out _);
+ });
+ _response.Clear();
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ bool ok = UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, _response, in limits, out int bytesRead);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(ok);
+ Assert.Equal(ResponseHeader.Length - 1, bytesRead);
+ Assert.Equal(200, _response.Status);
+ AssertAscii.Equal("OK", _response.ReasonPhrase);
+
+ Assert.Equal(ResponseHeader.Length, BackingArrayLength(_response.Version));
+ Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte body");
+ }
+
+ [Fact]
+ public void Response_HeaderOverLimit_Throws431_BeforeCopying()
+ {
+ var limits = ParserLimits.Default with { MaxTotalHeaderBytes = ResponseHeader.Length - 1 };
+ var seq = HeaderThenBody(ResponseHeader, BodySize);
+
+ WarmUp(() =>
+ {
+ var warm = HeaderThenBody(ResponseHeader, 16);
+ try { UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref warm, _response, in limits, out _); }
+ catch (HttpParseException) { }
+ });
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ var ex = Assert.Throws(
+ () => UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, _response, in limits, out _));
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(431, ex.StatusCode);
+ Assert.True(allocated < 1024 * 1024, $"allocated {allocated} bytes before rejecting");
+ }
+
+ // ================================================================
+ // FlexibleParser
+ // ================================================================
+
+ [Fact]
+ public void Flexible_PipelinedBody_IsNotCopied()
+ {
+ var seq = HeaderThenBody(RequestHeader, BodySize);
+
+ WarmUp(() =>
+ {
+ var warm = HeaderThenBody(RequestHeader, 16);
+ FlexibleParser.TryExtractFullHeader(ref warm, _request, out _);
+ });
+ _request.Clear();
+
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ bool ok = FlexibleParser.TryExtractFullHeader(ref seq, _request, out int bytesRead);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(ok);
+ Assert.Equal(RequestHeader.Length - 1, bytesRead);
+ AssertAscii.Equal("/upload", _request.Path);
+ Assert.Equal(2, _request.Headers.Count);
+
+ Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method));
+ Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte pipelined body");
+ }
+
+ // ================================================================
+ // Helpers
+ // ================================================================
+
+ private static void WarmUp(Action action)
+ {
+ // First calls JIT and initialize statics; keep that out of the allocation measurements.
+ action();
+ action();
+ }
+
+ private static int BackingArrayLength(ReadOnlyMemory slice)
+ {
+ Assert.True(MemoryMarshal.TryGetArray(slice, out ArraySegment segment));
+ return segment.Array!.Length;
+ }
+
+ ///
+ /// The header split across two segments, then body bytes in
+ /// 1 MiB segments.
+ ///
+ private static ReadOnlySequence HeaderThenBody(string header, int bodySize)
+ {
+ var head = Encoding.ASCII.GetBytes(header);
+ var parts = new List { head[..(head.Length / 2)], head[(head.Length / 2)..] };
+
+ for (int remaining = bodySize; remaining > 0; remaining -= 1024 * 1024)
+ parts.Add(new byte[Math.Min(remaining, 1024 * 1024)]);
+
+ return Segments([.. parts]);
+ }
+
+ private static ReadOnlySequence Segments(params byte[][] parts)
+ {
+ var first = new BufferSegment(parts[0]);
+ var last = first;
+ for (int i = 1; i < parts.Length; i++)
+ last = last.Append(parts[i]);
+
+ return new ReadOnlySequence(first, 0, last, last.Memory.Length);
+ }
+}