Skip to content

Fold repeated header lines per RFC 7230 §3.2.2#21

Merged
hellerve merged 1 commit into
masterfrom
claude/fold-repeated-headers
Jul 22, 2026
Merged

Fold repeated header lines per RFC 7230 §3.2.2#21
hellerve merged 1 commit into
masterfrom
claude/fold-repeated-headers

Conversation

@carpentry-agent

Copy link
Copy Markdown

Fold repeated header lines (RFC 7230 §3.2.2)

header-lookup returns only Array.unsafe-first of the first case-insensitively-matching header key, so every consumer of a header that a client may split across multiple lines (or repeat) saw just the first line. RFC 7230 §3.2.2 says a field whose value is a comma list may be split across multiple lines, and repeated lines must be treated as equivalent to one comma-joined line. Two shipped bugs followed from ignoring that:

  1. Chunked bodies with a split Transfer-Encoding were never decoded. transfer-encoding-chunked? split only the first Transfer-Encoding value on commas. A request with Transfer-Encoding: gzip then Transfer-Encoding: chunked on separate lines was read as just gzipchunked? false → the chunked body was left un-dechunked. This is the root cause the web library had to work around downstream in web #43.
  2. Accept content negotiation ignored all but the first Accept line. Request.negotiate read only the first Accept line, so a request that split Accept across two lines negotiated against the first line alone — a server offering only the media type named on the second line wrongly got no match.

The fix (minimal, additive, no new public API)

A new private header-values helper walks every key and, for each case-insensitively-matching one, appends its stored values into a single (Array String). Both accessors route through it:

  • transfer-encoding-chunked? now splits each value on commas, lowercases/trims each token, and returns whether any token is chunked — a presence query across all Transfer-Encoding codings. Single-line behavior is unchanged. (The "chunked must be last" validation nuance stays the caller's concern, e.g. web's gate — out of scope here.)
  • Request.negotiate folds all Accept values into one ", "-joined string before delegating to Accept.negotiate (whose parser already splits on commas) — exactly the §3.2.2 folding. No Accept header → the existing empty-string path.

header-lookup is left as-is for genuine singleton headers (Host, Content-Length, …). The helper is private+hidden: no header-combined/header-values accessor is exported, to avoid widening the public surface speculatively.

The parser already appends same-case repeated lines into one key's array (Map.update-with-default + Array.push-back, http.carp:339) and lands case-differing repeats in separate keys, so the fixtures exercise the real stored shape.

Tests

Added to test/http.carp, built from raw request strings via Request.parse (the existing pattern):

  • Negotiate: reusing the multi-header fixture (Accept: text/html then Accept: application/json), offering only the second line's type now selects application/json; the first-line type and a single-line Accept still negotiate correctly.
  • Chunked: a split same-case Transfer-Encoding: gzip / chunked and a mixed-case Transfer-Encoding / transfer-encoding variant (the separate-keys path) are now chunked? true; a gzip / deflate request stays false.

A/B verified: with the source fix stashed, the three differential assertions (multi-line negotiate, split same-case chunked, mixed-case chunked) fail against the old code, confirming they are non-vacuous; the guard assertions pass under both.

Verification

CARP_DIR=…/carp-lang carp -x test/http.carp178 passed, 0 failed (172 pre-existing + 6 new). carp-fmt -c and angler clean on both changed files.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

header-lookup returns only the first value of the first
case-insensitively-matching header key, so consumers of a header that
may be split across multiple lines (or repeated) saw only the first
line. Two shipped bugs followed:

- transfer-encoding-chunked? split only the first Transfer-Encoding
  value on commas, so a request with `Transfer-Encoding: gzip` then
  `Transfer-Encoding: chunked` on separate lines was read as "gzip" and
  its chunked body was never dechunked (the bug web #43 worked around
  downstream).
- Request.negotiate read only the first Accept line, so a request that
  split Accept across lines negotiated against that line alone.

Add a private header-values helper that gathers the values of every
case-insensitively-matching key, and route both accessors through it:
transfer-encoding-chunked? now looks for a chunked token across all
Transfer-Encoding values; negotiate folds all Accept values into one
comma list before delegating to Accept.negotiate. header-lookup is
unchanged for genuine singleton headers, and no public API was added.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/fold-repeated-headers at b250036e (base master, merge-base == origin/master). Built and ran test/http.carp on armhf: 178 passed, 0 failed — the 172 pre-existing plus all 6 new, including Request.negotiate folds an Accept split across multiple lines and Request.chunked? true when chunked is on a second Transfer-Encoding line. CI green on both runners. No CHANGELOG, correct — the repo has none.

Findings

No blocking findings. I checked the two things that matter most for a change like this — that the tests actually pin the bug, and that the helper doesn't widen the surface.

  • Non-vacuity confirmed by A/B. I restored http.carp from master (old single-line behavior) while keeping the new tests, and reran: 175 passed, 3 failed. Exactly the three differential assertions fail against the old code — negotiate folds a multi-line Accept, chunked? true on a second Transfer-Encoding line, and chunked? folds case-differing Transfer-Encoding lines — while the three guards (negotiate still honors the first line, negotiate honors a single-line Accept, chunked? false for gzip/deflate) pass under both versions. So the tests genuinely exercise the new path and can fail; they aren't passing for free.
  • The fix is at the right layer and correctly scoped. header-values is private + hidden, so no public accessor is added — it just folds every case-insensitively-matching key's values into one (Array String), and both transfer-encoding-chunked? and Request.negotiate route through it. header-lookup is untouched for genuine singletons. Empty cases are handled: no matching header → []chunked? false / negotiate takes the accept-anything path. This is the root-cause fix for the split-Transfer-Encoding miss that web #43 had to work around downstream.
  • One non-blocking observation. Values from keys that differ only in case are aggregated in Map iteration order, not header-line order. It doesn't matter for chunked? (a presence query). For negotiate the ", "-join feeds Accept.negotiate, which ranks by q-value, so cross-key ordering could only affect a tie-break between equal-q media types — RFC-acceptable. Repeated lines under the same key keep their order (the parser appends via Array.push-back), which is the common case. Not worth changing.
  • carp-fmt / angler clean (CI confirms).

Verdict: merge

Correct, minimal, no new public surface, and backed by a test set I confirmed is non-vacuous. It's a draft by your choice — leaving it as a draft for you to steer; the code is ready to un-draft whenever you're happy.

@hellerve
hellerve marked this pull request as ready for review July 22, 2026 10:44
@hellerve
hellerve merged commit de20290 into master Jul 22, 2026
2 checks passed
@hellerve
hellerve deleted the claude/fold-repeated-headers branch July 22, 2026 10:45
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.

1 participant