From 4fde0c469b70125631e93e5b8a4ebd1b09545ca9 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 15 Aug 2026 07:16:29 +0200 Subject: [PATCH 1/3] Update uri, time and strbuf to their latest releases uri 0.1.0 -> 0.5.0, time 0.5.0 -> 0.5.1, strbuf 0.1.0 -> 0.2.1. base64.carp is already on its latest tag, 0.2.0. Two behaviours change with the newer uri. uri 0.1.0's URI.str prepended an unconditional slash, so a URI with no path serialised as "/" and Request.str got a valid origin-form target by accident. uri 0.5.0 emits the slash only for a path that needs one, so the same URI serialises as "" and the request line becomes "GET HTTP/1.1", which is not a valid request line and does not re-parse. Request.target now applies RFC 9112 3.2.1 -- an origin-form target whose path component is empty is sent as "/" -- instead of relying on the URI serialiser. uri 0.5.0's URI.escape emits uppercase percent-encoding as RFC 3986 2.1 requires, so Form.encode now yields %2B rather than %2b. The expectation in the test is updated to match. --- http.carp | 21 +++++++++++++++++---- test/http.carp | 27 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/http.carp b/http.carp index 5d4f117..879cf07 100644 --- a/http.carp +++ b/http.carp @@ -1,7 +1,7 @@ (load "git@github.com:carpentry-org/base64.carp@0.2.0") -(load "git@github.com:carpentry-org/uri@0.1.0") -(load "git@github.com:carpentry-org/time@0.5.0") -(load "git@github.com:carpentry-org/strbuf@0.1.0") +(load "git@github.com:carpentry-org/uri@0.5.0") +(load "git@github.com:carpentry-org/time@0.5.1") +(load "git@github.com:carpentry-org/strbuf@0.2.1") (defmodule String (doc find-crlf @@ -310,12 +310,25 @@ attribute, whatever order the two appear in.") (doc ignore-body? "checks whether the body of the request should be ignored.") (defn ignore-body? [r] (= (verb r) "HEAD")) + (private target) + (hidden target) + ; RFC 9112 §3.2.1: an origin-form target with an empty path is sent as `/`. + (defn target [u] + (let [s (URI.str u)] + (if (and (Maybe.nothing? (URI.path u)) + (Maybe.nothing? (URI.scheme u)) + (Maybe.nothing? (URI.host u)) + (Maybe.nothing? (URI.user u)) + (Maybe.nothing? (URI.port u))) + (String.append "/" &s) + s))) + (doc str "stringifies the request as it looks on the wire.") (defn str [r] (let-do [sb (StringBuf.create)] (StringBuf.append-str &sb (verb r)) (StringBuf.append-char &sb \space) - (StringBuf.append-str &sb &(URI.str (uri r))) + (StringBuf.append-str &sb &(target (uri r))) (StringBuf.append-char &sb \space) (StringBuf.append-str &sb (version r)) (let [ks &(Map.keys (headers r))] diff --git a/test/http.carp b/test/http.carp index d75041d..40b0054 100644 --- a/test/http.carp +++ b/test/http.carp @@ -413,6 +413,29 @@ "GET" "request roundtrip preserves verb") + (assert-equal test + "GET / HTTP/1.1\r\n\r\n" + &(Request.str &(Request.get (URI.zero) [] {} @"")) + "request-target of an empty path is /") + + (assert-equal test + "GET /?a=1 HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get (URI.set-query (URI.zero) (Maybe.Just @"a=1")) [] {} @"")) + "request-target of a query-only URI keeps its leading /") + + (assert-equal test + "GET http://example.com HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get + (URI.set-scheme + (URI.set-host (URI.zero) (Maybe.Just @"example.com")) + (Maybe.Just @"http")) + [] + {} + @"")) + "absolute-form request-target is left alone") + (assert-true test (match (Cookie.parse "name=value") (Result.Success c) @@ -816,11 +839,11 @@ "encode & as %26") (assert-equal test - "k=a%2bb" + "k=a%2Bb" &(let-do [m (the (Map String String) {})] (Map.put! &m "k" "a+b") (Form.encode &m)) - "encode literal + as %2b") + "encode literal + as %2B") (assert-equal test "key+name=val" From 6ed1f9e124cb063f7bf2bd797dd4bad43378fbd8 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 15 Aug 2026 12:22:06 +0200 Subject: [PATCH 2/3] Pin the percent-decoding crash and byte-wise escaping the bump fixes uri 0.1.0's URI.unescape read one and two chars past the array on seeing a %, so a form body ending in a truncated escape aborted the process: Form.parse "k=b%4" and "k=b%" both exit 134 under the old pin. uri 0.5.0 bounds-checks and hex-tests both nibbles. The same bump moved escaping from codepoint-indexed to byte-indexed, so non-ASCII values now survive a Form.encode/Form.parse roundtrip; uri 0.1.0 masked each codepoint to its low byte and lost U+20AC as %ac. All four fail under the old dependency: the two encoding tests as plain mismatches, the two truncated-escape tests by killing the process. --- test/http.carp | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/http.carp b/test/http.carp index 40b0054..9743229 100644 --- a/test/http.carp +++ b/test/http.carp @@ -861,6 +861,37 @@ _ false)) "encode/parse roundtrip single pair") + (assert-equal test + "k=%E2%82%AC" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "k" "€") + (Form.encode &m)) + "encode a non-ASCII value one byte at a time") + + (assert-equal test + "€" + &(let-do [m (the (Map String String) {})] + (Map.put! &m "k" "€") + (match (the (Result (Map String String) String) + (Form.parse &(Form.encode &m))) + (Result.Success parsed) (Map.get &parsed "k") + _ @"")) + "encode/parse roundtrip a non-ASCII value") + + (assert-equal test + "b%4" + &(match (the (Result (Map String String) String) (Form.parse "k=b%4")) + (Result.Success m) (Map.get &m "k") + _ @"") + "parse a value ending in a truncated percent escape") + + (assert-equal test + "b%" + &(match (the (Result (Map String String) String) (Form.parse "k=b%")) + (Result.Success m) (Map.get &m "k") + _ @"") + "parse a value ending in a bare percent") + (assert-equal test 2 (match (the From cd46ef030138fae090dde2ea8a29c1c04cea08c3 Mon Sep 17 00:00:00 2001 From: Veit Heller Date: Sat, 15 Aug 2026 13:05:33 +0200 Subject: [PATCH 3/3] Pin the uri and time releases that carry the byte-consistency fixes --- http.carp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/http.carp b/http.carp index 879cf07..8326023 100644 --- a/http.carp +++ b/http.carp @@ -1,6 +1,6 @@ (load "git@github.com:carpentry-org/base64.carp@0.2.0") -(load "git@github.com:carpentry-org/uri@0.5.0") -(load "git@github.com:carpentry-org/time@0.5.1") +(load "git@github.com:carpentry-org/uri@0.5.1") +(load "git@github.com:carpentry-org/time@0.5.2") (load "git@github.com:carpentry-org/strbuf@0.2.1") (defmodule String