Skip to content

refactor(web): rebase RouteHostConstraint host:port splitting onto HttpHost [L03.01.02.01.09] - #934

Merged
dotnetcadet merged 3 commits into
mainfrom
feature/L03.01.02.01.09-route-host-httphost
Jul 20, 2026
Merged

refactor(web): rebase RouteHostConstraint host:port splitting onto HttpHost [L03.01.02.01.09]#934
dotnetcadet merged 3 commits into
mainfrom
feature/L03.01.02.01.09-route-host-httphost

Conversation

@dotnetcadet

Copy link
Copy Markdown
Contributor

Summary

Batch 5, stack 3/4 (base: feature/L01.01.11.43-h1-percent-decode, PR #933).

The #781 host-filtering work gave core HttpHost a structural host[:port] split deliberately mirroring RouteHostConstraint's; the constraint (from #788) still carried its own private copy. This rebases the constraint onto the shared primitive so the SELECT (routing) and VALIDATE (filtering) paths cannot drift.

  • Case-by-case semantic diff first: the two splits were identical in every case except the present-but-invalid port family — the constraint's two-stage split defers port rejection (enabling [L03.01.02.01.06] Add host-based route matching (RequireHost equivalent) #788's documented leniency: a port-unconstrained route tolerates junk port text), while TryGetComponents' fused parse refuses the whole host. Direct delegation would have broken the leniency.
  • Resolution: extracted the structural split as internal static HttpHost.TrySplitHostPort (byte-identical to the constraint's old code), widened TryParsePort to internal, and rebuilt TryGetComponents as a layer over them (verified against all 21 existing HttpHostTests cases). RouteHostConstraint now delegates; its duplicated private split/parse is deleted (−84 lines). Shared helpers stay internal with a rationale-commented InternalsVisibleTo("Assimalign.Cohesion.Web.Routing") — no public surface growth, no reference-graph change (the sanctioned "shared span helper in core Http" shape from the issue).
  • The [L03.01.02.01.06] Add host-based route matching (RequireHost equivalent) #788 leniency is now pinned by regression tests (9 tolerance cases incl. the port-constrained contrast) plus 4 structural-boundary cases; both DESIGN.md files updated to say parity is shared code, not mirrored copies.

Tests

Web.Routing 263 (250 pre-existing UNCHANGED + 13 new) · core Http 1231 · Web.HostFiltering 14 · Web.HttpsPolicy 32 — all passing. No public-surface change to either type.

Closes #890

🤖 Generated with Claude Code

dotnetcadet and others added 3 commits July 20, 2026 10:48
…ient sees the response terminate [L01.01.11.44]

An HTTP/3 response body is delimited by the request stream's end (RFC 9114
§4.1), not by Content-Length. The h3 send path wrote HEADERS/DATA, flushed,
and returned without ending the request stream's write side, so a real .NET
HTTP/3 client stayed in ReadResponseContentAsync waiting for a FIN that only
arrived at connection teardown — at which point the control/QPACK critical
streams closing surfaced at the client as H3_CLOSED_CRITICAL_STREAM (0x104).

The buffered SendAsync and the streaming sink's CompleteFramedAsync now end
the request stream's write side (graceful QUIC FIN via the IConnection
half-close contract, Output.Complete()) once the response is fully flushed.
Best-effort: a teardown race that disposed the stream is swallowed.

Both #928 send observations resolve here: a bodyless 200 completes the
client's zero-length drain via the FIN, and the buffered-body content length
already matched the DATA written (ReadBodyAsync reads the whole buffer,
position-independent). Adds platform-guarded real-QUIC round-trip regression
tests (full round-trip, bodyless 200, end-position body, sequential requests
pinning critical-stream lifetime) and flips the Web.Hosting UseHttp3 e2e from
best-effort to a hard client-observed status+body assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…op StaticFiles compensation [L01.01.11.43]

Http1MessageReader now surfaces IHttpRequest.Path from the origin-form
request-target through the same HttpPath.FromUriComponent decode HTTP/2
(Http2Stream) and HTTP/3 (Http3HeaderCodec) run over the :path pseudo-header,
so identical wire bytes yield an identical decoded path on every transport:
%2e%2e decodes to "..", %2F stays encoded (never a separator), ordinary octets
decode, and invalid/overlong escapes are left intact per UrlDecoder. A decoded
octet that is not a legal path character (space, control, ?/#, NUL) makes the
target malformed and is surfaced as the reader's existing wire-level failure.
Only origin-form is decoded; absolute/authority/asterisk keep their handling.
The query is unchanged — already split before the decode and parsed by
HttpQuery.Parse identically on all transports.

Removes the now-double-decoding version-gated compensation in
Web.StaticFiles.StaticFilesMiddleware (Http11 + Uri.UnescapeDataString) in the
same change and re-points its tests: the encoded-traversal/name decode is pinned
at the transport (new Http.Connections parity suite + the E2E traversal/name
round-trips), and a middleware test guards against re-decoding a handed path.

Tests: Http.Connections 434, Web.StaticFiles 85, Web.Routing 250, Http core
1231 — all green. Updates both touched DESIGN.md files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ared HttpHost primitive [L03.01.02.01.09]

Host filtering (#781) added a structural host[:port] split to HttpHost
(TryGetComponents) that deliberately mirrored RouteHostConstraint's own
private TrySplitHostAndPort/TryParsePort (#788). The two copies could drift,
so route host SELECTION and host allowlist VALIDATION might disagree on what a
wire value means.

Extract the structural split as an internal HttpHost.TrySplitHostPort and widen
TryParsePort to internal (granted to Web.Routing via InternalsVisibleTo).
TryGetComponents now layers its stricter port validation on top of that shared
split, byte-for-byte unchanged. RouteHostConstraint.TryParse/IsMatch delegate to
the shared helpers and the duplicated private copies are deleted.

Direct delegation to TryGetComponents was impossible without changing behavior:
it fuses port validation into the split and returns false (no host) for a
present-but-invalid port, which would break the documented #788 leniency where a
port-unconstrained route tolerates junk port text in the request host and matches
on the host part alone. The shared *structural* split preserves that leniency
exactly; new RouteHostConstraintTests cases pin both the leniency and its
structural boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from feature/L01.01.11.43-h1-percent-decode to main July 20, 2026 18:23
@dotnetcadet
dotnetcadet merged commit 707e3a7 into main Jul 20, 2026
114 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[L03.01.02.01.09] Rebase RouteHostConstraint host:port splitting onto the HttpHost component primitives

1 participant