From b250036ef75434615c92b98f9ffc2c0b4e77ba41 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Tue, 21 Jul 2026 22:55:47 +0200 Subject: [PATCH] =?UTF-8?q?fold=20repeated=20header=20lines=20per=20RFC=20?= =?UTF-8?q?7230=20=C2=A73.2.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- http.carp | 27 ++++++++++++++++++++------- test/http.carp | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/http.carp b/http.carp index 810d929..f30bff7 100644 --- a/http.carp +++ b/http.carp @@ -183,15 +183,27 @@ the type yourself, instead you can [parse](#parse) it.") (the (Maybe String) (Maybe.Nothing)) hdrs))) +(private header-values) +(hidden header-values) +(defn header-values [hdrs name] + (let [lower-name &(String.ascii-to-lower name)] + (Map.kv-reduce + &(fn [acc k v] + (if (= &(String.ascii-to-lower k) lower-name) + (Array.concat &[acc @v]) + acc)) + (the (Array String) []) + hdrs))) + (private transfer-encoding-chunked?) (hidden transfer-encoding-chunked?) (defn transfer-encoding-chunked? [hdrs] - (match (header-lookup hdrs "transfer-encoding") - (Maybe.Just v) + (Array.any? + &(fn [v] (Array.any? &(fn [tok] (= &(String.trim tok) "chunked")) - &(String.split-by &(String.ascii-to-lower &v) &[\,])) - (Maybe.Nothing) false)) + &(String.split-by &(String.ascii-to-lower v) &[\,]))) + &(header-values hdrs "transfer-encoding"))) (doc Request "is a request data type. It holds the `verb` of the request, the `version`, the `uri`, the `cookies`, the `headers`, and the `body`.") @@ -1317,6 +1329,7 @@ server's `offers`, honoring the request's `Accept` header (RFC 7231 ยง5.3.2). A request with no `Accept` header accepts anything. See [`Accept.negotiate`](#negotiate).") (defn negotiate [r offers] - (match (Request.header r "Accept") - (Maybe.Nothing) (Accept.negotiate "" offers) - (Maybe.Just a) (Accept.negotiate &a offers)))) + (let [accepts (header-values (headers r) "Accept")] + (if (Array.empty? &accepts) + (Accept.negotiate "" offers) + (Accept.negotiate &(String.join ", " &accepts) offers))))) diff --git a/test/http.carp b/test/http.carp index 98ee71a..147e8e3 100644 --- a/test/http.carp +++ b/test/http.carp @@ -67,6 +67,14 @@ (match (Request.negotiate &r offers) (Maybe.Just m) m (Maybe.Nothing) @"NONE"))) + +(defn req-parse-pick [s offers] + (match (Request.parse s) + (Result.Success r) + (match (Request.negotiate &r offers) + (Maybe.Just m) m + (Maybe.Nothing) @"NONE") + _ @"PARSE-FAIL")) ; ---- chunked transfer-encoding test helpers ---- (defn dechunk-ok [s] (match (TransferEncoding.dechunk s) (Result.Success b) b _ @"FAIL")) @@ -807,6 +815,20 @@ "text/html" &(req-pick-noaccept &[@"text/html" @"application/json"]) "Request.negotiate with no Accept header accepts anything") + (assert-equal test + "application/json" + &(req-parse-pick &multi-header &[@"application/json"]) + "Request.negotiate folds an Accept split across multiple lines") + (assert-equal test + "text/html" + &(req-parse-pick &multi-header &[@"text/html" @"application/json"]) + "Request.negotiate still honors the first line of a multi-line Accept") + (assert-equal test + "text/html" + &(req-parse-pick + "GET / HTTP/1.1\r\nHost: localhost\r\nAccept: text/html\r\n\r\n" + &[@"text/html"]) + "Request.negotiate honors a single-line Accept") (assert-equal test &(dechunk-ok "4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n") "Wikipedia" @@ -887,6 +909,21 @@ (req-chunked? "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n") "Request.chunked? true for Transfer-Encoding: chunked") + (assert-true test + (req-chunked? + "POST / HTTP/1.1\r\nTransfer-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n") + "Request.chunked? true when chunked is on a second Transfer-Encoding line") + + (assert-true test + (req-chunked? + "POST / HTTP/1.1\r\nTransfer-Encoding: gzip\r\ntransfer-encoding: chunked\r\n\r\n") + "Request.chunked? folds Transfer-Encoding lines that differ only in case") + + (assert-false test + (req-chunked? + "POST / HTTP/1.1\r\nTransfer-Encoding: gzip\r\nTransfer-Encoding: deflate\r\n\r\n") + "Request.chunked? false when no Transfer-Encoding line lists chunked") + ; ---- Cache-Control ---- (assert-equal test 3600