diff --git a/src/Glyph11/Glyph11.csproj b/src/Glyph11/Glyph11.csproj
index 7702467..06acc97 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.6.0
- 0.3.6.0
- 0.3.6
+ 0.3.7.0
+ 0.3.7.0
+ 0.3.7
true
diff --git a/src/Glyph11/Parser/ParserLimits.cs b/src/Glyph11/Parser/ParserLimits.cs
index 556c4a0..f118c1d 100644
--- a/src/Glyph11/Parser/ParserLimits.cs
+++ b/src/Glyph11/Parser/ParserLimits.cs
@@ -27,6 +27,12 @@ public readonly record struct ParserLimits
/// Maximum length of the HTTP method token in bytes (default 16).
public int MaxMethodLength { get; init; }
+ ///
+ /// Maximum length of a response's reason phrase in bytes (default 512). Responses only; a
+ /// request has no equivalent field.
+ ///
+ public int MaxReasonPhraseLength { get; init; }
+
/// Maximum total size of the header block including request line and terminators (default 1048576).
public int MaxTotalHeaderBytes { get; init; }
@@ -39,6 +45,7 @@ public readonly record struct ParserLimits
MaxUrlLength = 8192,
MaxQueryParameterCount = 128,
MaxMethodLength = 16,
+ MaxReasonPhraseLength = 512,
MaxTotalHeaderBytes = 1_048_576
};
}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs
new file mode 100644
index 0000000..078e681
--- /dev/null
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs
@@ -0,0 +1,240 @@
+using System.Runtime.CompilerServices;
+using Glyph11.Protocol;
+
+namespace Glyph11.Parser.UltraHardened;
+
+public static partial class UltraHardenedParser
+{
+ ///
+ /// Combined parse + semantic validation of a RESPONSE header — single-segment hot path.
+ ///
+ /// Returns false if incomplete; throws if
+ /// structurally or semantically invalid.
+ ///
+ ///
+ ///
+ /// The header block is parsed by the same rules as a request's — field-lines are identical in
+ /// both directions, so obs-fold, bare LF, whitespace before the colon and the
+ /// Transfer-Encoding/Content-Length smuggling checks all carry over unchanged.
+ ///
+ /// What does not carry over: there is no Host rule (that is a request requirement), no
+ /// request-target, and the first line is HTTP-version SP status-code SP [reason-phrase]
+ /// rather than METHOD SP target SP HTTP-version.
+ ///
+ ///
+ /// Whether a body follows at all is not decidable from the response alone — HEAD and 304 are
+ /// framed by the request that produced them. See
+ /// .
+ ///
+ ///
+ [SkipLocalsInit]
+ public static bool TryExtractFullResponseHeaderROM(
+ ref ReadOnlyMemory input, BinaryResponse response,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ bytesReadCount = -1;
+ var span = input.Span;
+
+ int headerEnd = span.IndexOf(ParserConstants.CrlfCrlf);
+ if (headerEnd < 0) return false;
+
+ int totalHeaderBytes = headerEnd + 4;
+ if (totalHeaderBytes > limits.MaxTotalHeaderBytes)
+ throw new HttpParseException("Total header size exceeds limit.", statusCode: 431);
+
+ // ---- Status line: HTTP-version SP status-code SP [ reason-phrase ] CRLF — RFC 9112 §4 ----
+
+ int statusLineEnd = span.IndexOf(ParserConstants.Crlf);
+ if (statusLineEnd < 0)
+ throw new HttpParseException("Invalid HTTP/1.1 status line.");
+
+ var statusLine = span[..statusLineEnd];
+
+ // ---- Reject bare LF in status line — RFC 9112 §2.2 ----
+ if (statusLine.IndexOf((byte)'\n') >= 0)
+ throw new HttpParseException("Bare LF detected; only CRLF line endings are allowed.");
+
+ int firstSpace = statusLine.IndexOf(ParserConstants.Space);
+ if (firstSpace < 0)
+ throw new HttpParseException("Invalid status line: missing status code.");
+
+ // --- Version ---
+ var versionSpan = statusLine[..firstSpace];
+ if (!ParserConstants.IsValidHttpVersion(versionSpan))
+ throw new HttpParseException("Invalid HTTP version.", 505);
+
+ response.Version = input[..firstSpace];
+
+ // --- Status code: exactly three digits — RFC 9112 §4 ---
+ int codeStart = firstSpace + 1;
+ if (codeStart + 3 > statusLine.Length)
+ throw new HttpParseException("Invalid status line: truncated status code.");
+
+ var codeSpan = statusLine.Slice(codeStart, 3);
+ if (!ParserConstants.IsDigit(codeSpan[0]) ||
+ !ParserConstants.IsDigit(codeSpan[1]) ||
+ !ParserConstants.IsDigit(codeSpan[2]))
+ throw new HttpParseException("Status code must be exactly three digits.");
+
+ int status = ((codeSpan[0] - '0') * 100) + ((codeSpan[1] - '0') * 10) + (codeSpan[2] - '0');
+
+ // A three-digit code below 100 is well-formed but not a status — 0xx has no meaning and
+ // treating it as one lets a garbage first line pass as a response.
+ if (status < 100)
+ throw new HttpParseException("Status code must be in the range 100-599.");
+
+ response.StatusCode = input.Slice(codeStart, 3);
+ response.Status = status;
+
+ // --- Reason phrase (optional) ---
+ int afterCode = codeStart + 3;
+ if (afterCode == statusLine.Length)
+ {
+ // "HTTP/1.1 200" with no trailing space. The grammar asks for the SP even when the
+ // phrase is empty, but origins do send this and it is not a parsing ambiguity, so it
+ // is accepted rather than turned into an interop failure.
+ response.ReasonPhrase = default;
+ }
+ else
+ {
+ if (statusLine[afterCode] != ParserConstants.Space)
+ throw new HttpParseException("Invalid status line: status code must be three digits.");
+
+ int reasonStart = afterCode + 1;
+ int reasonLen = statusLine.Length - reasonStart;
+
+ if (reasonLen > limits.MaxReasonPhraseLength)
+ throw new HttpParseException("Reason phrase length exceeds limit.", statusCode: 431);
+
+ // reason-phrase = 1*( HTAB / SP / VCHAR / obs-text ) — the same character set a
+ // field-value admits, which is what makes this check reusable. It is a charset check
+ // and nothing more: the phrase is free text and carries no meaning to act on.
+ var reasonSpan = statusLine.Slice(reasonStart, reasonLen);
+ if (!ParserConstants.IsValidFieldValue(reasonSpan))
+ throw new HttpParseException("Reason phrase contains invalid characters.");
+
+ response.ReasonPhrase = input.Slice(reasonStart, reasonLen);
+ }
+
+ // ---- Headers (structural parse + inline semantic checks) ----
+
+ int lineStart = statusLineEnd + 2;
+ int headerCount = 0;
+
+ // Semantic state tracked across headers
+ bool hasCL = false;
+ bool hasTE = false;
+ ReadOnlySpan firstCLValue = default;
+
+ while (true)
+ {
+ int lineLen = span[lineStart..].IndexOf(ParserConstants.Crlf);
+ if (lineLen < 0)
+ throw new HttpParseException("Invalid headers.");
+
+ if (lineLen == 0)
+ break;
+
+ var line = span.Slice(lineStart, lineLen);
+
+ // ---- Reject bare LF in header line — RFC 9112 §2.2 ----
+ if (line.IndexOf((byte)'\n') >= 0)
+ throw new HttpParseException("Bare LF detected; only CRLF line endings are allowed.");
+
+ // ---- Reject obs-fold (line starting with SP/HTAB) — RFC 9112 §5.2 ----
+ if (line[0] == (byte)' ' || line[0] == (byte)'\t')
+ throw new HttpParseException("Obsolete line folding (obs-fold) is not allowed.");
+
+ int colon = line.IndexOf(ParserConstants.Colon);
+
+ if (colon <= 0)
+ throw new HttpParseException(colon == 0
+ ? "Header name is empty."
+ : "Malformed header line: missing colon.");
+
+ // ---- Reject whitespace between field-name and colon — RFC 9112 §5.1 ----
+ if (line[colon - 1] == (byte)' ' || line[colon - 1] == (byte)'\t')
+ throw new HttpParseException("Whitespace between header name and colon is not allowed.");
+
+ // Validate header name
+ var nameSpan = line[..colon];
+ if (nameSpan.Length > limits.MaxHeaderNameLength)
+ throw new HttpParseException("Header name length exceeds limit.", statusCode: 431);
+ if (!ParserConstants.IsValidToken(nameSpan))
+ throw new HttpParseException("Header name contains invalid token characters.");
+
+ // Trim leading OWS from value
+ int valAbsStart = lineStart + colon + 1;
+ while (valAbsStart < lineStart + lineLen)
+ {
+ byte b = span[valAbsStart];
+ if (b != (byte)' ' && b != (byte)'\t') break;
+ valAbsStart++;
+ }
+
+ int valLen = (lineStart + lineLen) - valAbsStart;
+
+ // Validate header value
+ var valueSpan = span.Slice(valAbsStart, valLen);
+ if (valLen > limits.MaxHeaderValueLength)
+ throw new HttpParseException("Header value length exceeds limit.", statusCode: 431);
+ if (!ParserConstants.IsValidFieldValue(valueSpan))
+ throw new HttpParseException("Header value contains invalid characters.");
+
+ if (++headerCount > limits.MaxHeaderCount)
+ throw new HttpParseException("Header count exceeds limit.", statusCode: 431);
+
+ response.Headers.Add(
+ input.Slice(lineStart, colon),
+ input.Slice(valAbsStart, valLen));
+
+ // ---- Inline semantic checks keyed by header name ----
+ // Length pre-check avoids the full case-insensitive compare for most headers.
+
+ if (nameSpan.Length == 14 && ParserConstants.AsciiEqualsIgnoreCase(nameSpan, ContentLengthName))
+ {
+ // RFC 9110 §8.6 — validate format (syntax, leading zeros, overflow)
+ if (!SemIsValidContentLengthValue(valueSpan))
+ throw new HttpParseException("Invalid Content-Length format.");
+
+ // RFC 9112 §6.2 — comma-separated values must all be identical
+ if (SemHasConflictingCommaSeparatedCL(valueSpan))
+ throw new HttpParseException("Conflicting comma-separated Content-Length values.");
+
+ // RFC 9110 §8.6 — multiple CL headers must have identical values
+ if (hasCL)
+ {
+ if (!valueSpan.SequenceEqual(firstCLValue))
+ throw new HttpParseException("Conflicting Content-Length headers.");
+ }
+ else
+ {
+ firstCLValue = valueSpan;
+ hasCL = true;
+ }
+ }
+ else if (nameSpan.Length == 17 && ParserConstants.AsciiEqualsIgnoreCase(nameSpan, ParserConstants.TransferEncodingName))
+ {
+ hasTE = true;
+
+ // RFC 9112 §6.1 — only "chunked" is accepted
+ var trimmed = SemTrimOWS(valueSpan);
+ if (!ParserConstants.AsciiEqualsIgnoreCase(trimmed, ParserConstants.ChunkedValue))
+ throw new HttpParseException("Invalid Transfer-Encoding value; only 'chunked' is accepted.");
+ }
+
+ lineStart += lineLen + 2;
+ }
+
+ // ---- Post-loop cross-header semantic checks ----
+
+ // RFC 9112 §6.1 — TE + CL together is a desync vector in this direction too: a proxy that
+ // frames one way and a client that frames the other disagree on where the response ends,
+ // and the remainder becomes the head of the next one.
+ if (hasTE && hasCL)
+ throw new HttpParseException("Both Transfer-Encoding and Content-Length are present.");
+
+ bytesReadCount += totalHeaderBytes;
+ return true;
+ }
+}
diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
new file mode 100644
index 0000000..3ead9ce
--- /dev/null
+++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs
@@ -0,0 +1,50 @@
+using System.Buffers;
+using Glyph11.Protocol;
+
+namespace Glyph11.Parser.UltraHardened;
+
+public static partial class UltraHardenedParser
+{
+ ///
+ /// 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.
+ /// Bytes consumed on success, 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.
+ ///
+ /// 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.
+ ///
+ public static bool TryExtractFullResponseHeaderValidated(
+ ref ReadOnlySequence input, BinaryResponse response,
+ in ParserLimits limits, out int bytesReadCount)
+ {
+ if (input.IsSingleSegment)
+ {
+ ReadOnlyMemory singleMemorySegment = input.First;
+ return TryExtractFullResponseHeaderROM(ref singleMemorySegment, response, in limits, out bytesReadCount);
+ }
+
+ // Check for header completeness before allocating
+ var reader = new SequenceReader(input);
+ if (!reader.TryReadTo(out ReadOnlySequence _, ParserConstants.CrlfCrlf, advancePastDelimiter: true))
+ {
+ bytesReadCount = -1;
+ 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);
+ }
+}
diff --git a/src/Glyph11/Protocol/BinaryResponse.cs b/src/Glyph11/Protocol/BinaryResponse.cs
new file mode 100644
index 0000000..a943dc9
--- /dev/null
+++ b/src/Glyph11/Protocol/BinaryResponse.cs
@@ -0,0 +1,63 @@
+namespace Glyph11.Protocol;
+
+///
+/// Holds the parsed components of an HTTP/1.1 response header.
+/// All byte-level fields are slices that reference
+/// the original input buffer (zero-copy on the single-segment path).
+///
+/// Reuse instances across responses by calling between parses.
+/// Call when the instance is no longer needed to return
+/// pooled arrays used by .
+///
+///
+public sealed class BinaryResponse : IDisposable
+{
+ private readonly KeyValueList _headers = new();
+
+ /// HTTP version string, e.g. "HTTP/1.1". Set by UltraHardenedParser only.
+ public ReadOnlyMemory Version { get; internal set; }
+
+ ///
+ /// Status code as the three bytes that arrived, e.g. "404". is the same
+ /// value already parsed, and is what callers normally want.
+ ///
+ public ReadOnlyMemory StatusCode { get; internal set; }
+
+ /// Status code as an integer, 100-599.
+ public int Status { get; internal set; }
+
+ ///
+ /// Reason phrase, e.g. "Not Found". Empty is legal and common: HTTP/2 and HTTP/3 have no
+ /// reason phrase at all, so anything translating from them emits none.
+ ///
+ public ReadOnlyMemory ReasonPhrase { get; internal set; }
+
+ /// Parsed HTTP headers as key-value pairs.
+ public KeyValueList Headers => _headers;
+
+ /// Response body bytes. Not populated by the header parser.
+ public ReadOnlyMemory Body { get; internal set; }
+
+ ///
+ /// Resets the response for reuse. Clears headers but keeps the underlying pooled
+ /// arrays allocated.
+ ///
+ public void Clear()
+ {
+ Version = default;
+ StatusCode = default;
+ Status = 0;
+ ReasonPhrase = default;
+ Body = default;
+ _headers.Clear();
+ }
+
+ ///
+ /// Returns pooled arrays to .
+ /// The instance should not be used after disposal.
+ ///
+ public void Dispose()
+ {
+ _headers.Dispose();
+ }
+}
diff --git a/src/Glyph11/Validation/BodyFraming.cs b/src/Glyph11/Validation/BodyFraming.cs
index 8d5246b..92859f9 100644
--- a/src/Glyph11/Validation/BodyFraming.cs
+++ b/src/Glyph11/Validation/BodyFraming.cs
@@ -15,6 +15,13 @@ public enum BodyFraming : byte
/// Chunked transfer-encoding — use .
Chunked,
+
+ ///
+ /// Responses only: the body runs until the peer closes the connection (RFC 9112 §6.3). A
+ /// request can never be framed this way — a client that closed its send side to delimit a body
+ /// would have no way left to read the answer.
+ ///
+ UntilClose,
}
///
@@ -46,4 +53,7 @@ private BodyFramingResult(BodyFraming framing, long contentLength)
/// Chunked transfer-encoding.
public static BodyFramingResult ForChunked => new(BodyFraming.Chunked, 0);
+
+ /// Body delimited by connection close — responses only.
+ public static BodyFramingResult ForUntilClose => new(BodyFraming.UntilClose, 0);
}
diff --git a/src/Glyph11/Validation/BodyFramingDetector.cs b/src/Glyph11/Validation/BodyFramingDetector.cs
index 79dd85f..4f26e0d 100644
--- a/src/Glyph11/Validation/BodyFramingDetector.cs
+++ b/src/Glyph11/Validation/BodyFramingDetector.cs
@@ -12,6 +12,8 @@ public static class BodyFramingDetector
private static ReadOnlySpan TransferEncodingName => "transfer-encoding"u8;
private static ReadOnlySpan ContentLengthName => "content-length"u8;
private static ReadOnlySpan ChunkedValue => "chunked"u8;
+ private static ReadOnlySpan HeadMethodName => "head"u8;
+ private static ReadOnlySpan ConnectMethodName => "connect"u8;
///
/// Inspects the parsed headers in and returns the body
@@ -54,6 +56,81 @@ public static BodyFramingResult DetectBodyFraming(BinaryRequest request)
return BodyFramingResult.NoBody;
}
+ ///
+ /// Inspects the parsed headers in and returns the body framing
+ /// kind. Single pass over headers.
+ ///
+ /// The parsed response header.
+ ///
+ /// The method of the request that produced this response. It is REQUIRED, and not a
+ /// convenience: a response to HEAD carries the Content-Length the body would have had and no
+ /// body, so framing a response by its own headers alone reads the next response as this one's
+ /// content. Pass the method exactly as sent.
+ ///
+ ///
+ /// Precedence follows RFC 9112 §6: the status and method decide whether a body can exist at
+ /// all, then Transfer-Encoding, then Content-Length, and a response with none of those runs
+ /// until the connection closes.
+ ///
+ public static BodyFramingResult DetectResponseBodyFraming(
+ BinaryResponse response, ReadOnlySpan requestMethod)
+ {
+ int status = response.Status;
+
+ // RFC 9112 §6.3 — these carry no body whatever the headers claim.
+ if (status is >= 100 and < 200 || status == 204 || status == 304)
+ return BodyFramingResult.NoBody;
+
+ // A HEAD response is a GET response with the body removed: the framing headers describe a
+ // body that is not there.
+ if (ParserConstants.AsciiEqualsIgnoreCase(requestMethod, HeadMethodName))
+ return BodyFramingResult.NoBody;
+
+ // A 2xx to CONNECT means the tunnel is open and everything after the header is opaque
+ // relay traffic, not an HTTP body.
+ if (status is >= 200 and < 300 &&
+ ParserConstants.AsciiEqualsIgnoreCase(requestMethod, ConnectMethodName))
+ return BodyFramingResult.NoBody;
+
+ var headers = response.Headers;
+ ReadOnlySpan contentLengthValue = default;
+ bool hasChunkedTE = false;
+
+ for (int i = 0; i < headers.Count; i++)
+ {
+ var name = headers[i].Key.Span;
+
+ if (name.Length == 17 && ParserConstants.AsciiEqualsIgnoreCase(name, TransferEncodingName))
+ {
+ var value = TrimOws(headers[i].Value.Span);
+ if (ParserConstants.AsciiEqualsIgnoreCase(value, ChunkedValue))
+ hasChunkedTE = true;
+ }
+ else if (name.Length == 14 && ParserConstants.AsciiEqualsIgnoreCase(name, ContentLengthName))
+ {
+ contentLengthValue = TrimOws(headers[i].Value.Span);
+ }
+ }
+
+ // Chunked takes priority over Content-Length (RFC 9112 §6.1)
+ if (hasChunkedTE)
+ return BodyFramingResult.ForChunked;
+
+ if (!contentLengthValue.IsEmpty)
+ {
+ long cl = ParseContentLengthDigits(contentLengthValue);
+ if (cl > 0)
+ return BodyFramingResult.ForContentLength(cl);
+ if (cl == 0)
+ return BodyFramingResult.NoBody;
+ }
+
+ // No framing header at all. Unlike a request - which is simply bodyless here - a response
+ // runs to end of connection (RFC 9112 §6.3), which is also the only framing HTTP/1.0
+ // origins ever had.
+ return BodyFramingResult.ForUntilClose;
+ }
+
private static ReadOnlySpan TrimOws(ReadOnlySpan value)
{
int start = 0;
diff --git a/tests/Tests/UltraHardenedParser.Response.cs b/tests/Tests/UltraHardenedParser.Response.cs
new file mode 100644
index 0000000..20c01b6
Binary files /dev/null and b/tests/Tests/UltraHardenedParser.Response.cs differ