From 1ee3526afb135e4f03a8ccce6fb5db01852841dc Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 26 Oct 2026 01:01:41 +0000 Subject: [PATCH] Keep the caller's name for Content-Length and Transfer-Encoding The HTTP/1 request writer replaces a caller's Content-Length, Transfer-Encoding or Trailer under any case with the value from the request, written under the canonical name (#32). A request whose headers are all lower case, as Node's fetch writes them, therefore goes out with "Content-Length" among lower-case headers, which changes how the request looks. Keep #32's single header and computed values, but write them under the name the caller used. If the caller used more than one spelling, or none, the name is canonical, as before. Co-Authored-By: Claude Opus 5.5 --- requestwrite_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ transfer.go | 28 +++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/requestwrite_test.go b/requestwrite_test.go index 53dee7bd..96d6bcdc 100644 --- a/requestwrite_test.go +++ b/requestwrite_test.go @@ -12,6 +12,7 @@ import ( "io" "net" "net/url" + "slices" "strings" "testing" "testing/iotest" @@ -978,3 +979,49 @@ func (c *dumpConn) RemoteAddr() net.Addr { return nil } func (c *dumpConn) SetDeadline(t time.Time) error { return nil } func (c *dumpConn) SetReadDeadline(t time.Time) error { return nil } func (c *dumpConn) SetWriteDeadline(t time.Time) error { return nil } + +// The transfer headers take their values from the request but keep the name +// the caller wrote. +func TestRequestWriteKeepsTransferHeaderNames(t *testing.T) { + tests := []struct { + name string + method string + body io.Reader + length int64 + header Header + trailer Header + want []string // transfer header lines, in order + }{ + {"lowercase", "POST", strings.NewReader("abc"), 3, Header{"content-length": {"3"}}, nil, []string{"content-length: 3"}}, + {"uppercase with a wrong value", "POST", strings.NewReader("abc"), 3, Header{"CONTENT-LENGTH": {"99"}}, nil, []string{"CONTENT-LENGTH: 3"}}, + {"two spellings", "POST", strings.NewReader("abc"), 3, Header{"content-length": {"3"}, "Content-length": {"3"}}, nil, []string{"Content-Length: 3"}}, + {"GET drops it", "GET", nil, 0, Header{"content-length": {"3"}}, nil, nil}, + {"lowercase transfer-encoding", "POST", io.MultiReader(strings.NewReader("abc")), -1, Header{"transfer-encoding": {"chunked"}}, nil, []string{"transfer-encoding: chunked"}}, + {"lowercase trailer", "POST", io.MultiReader(strings.NewReader("abc")), -1, Header{"trailer": {"x"}}, Header{"X-Foo": nil}, []string{"Transfer-Encoding: chunked", "trailer: X-Foo"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := dummyRequest(tt.method) + req.Header, req.ContentLength, req.Trailer = tt.header, tt.length, tt.trailer + if tt.body != nil { + req.Body = io.NopCloser(tt.body) + } + var buf bytes.Buffer + if err := req.Write(&buf); err != nil { + t.Fatal(err) + } + head, _, _ := strings.Cut(buf.String(), "\r\n\r\n") + var got []string + for _, line := range strings.Split(head, "\r\n")[1:] { + name, _, _ := strings.Cut(line, ":") + switch strings.ToLower(name) { + case "content-length", "transfer-encoding", "trailer": + got = append(got, line) + } + } + if !slices.Equal(got, tt.want) { + t.Errorf("transfer headers %q, want %q\n%s", got, tt.want, head) + } + }) + } +} diff --git a/transfer.go b/transfer.go index bdb30abe..9e24cdcb 100644 --- a/transfer.go +++ b/transfer.go @@ -275,6 +275,8 @@ func (t *transferWriter) shouldSendContentLength() bool { // addHeaders adds transfer headers to an existing header object, replacing any the caller set with the same name. // Content-Length always comes from the request, as in net/http. func (t *transferWriter) addHeaders(hdrs *Header) error { + // Not setTransferHeader: the caller's Content-Length goes even when none is sent. + contentLength := headerNameFold(*hdrs, "Content-Length") deleteHeaderFold(*hdrs, "Content-Length") if t.Close && !hasToken(t.Header.get("Connection"), "close") { hdrs.Add("Connection", "close") @@ -284,7 +286,7 @@ func (t *transferWriter) addHeaders(hdrs *Header) error { // function of the sanitized field triple (Body, ContentLength, // TransferEncoding) if t.shouldSendContentLength() { - setTransferHeader(*hdrs, "Content-Length", strconv.FormatInt(t.ContentLength, 10)) + (*hdrs)[contentLength] = []string{strconv.FormatInt(t.ContentLength, 10)} } else if chunked(t.TransferEncoding) { setTransferHeader(*hdrs, "Transfer-Encoding", "chunked") } @@ -311,10 +313,30 @@ func (t *transferWriter) addHeaders(hdrs *Header) error { return nil } -// setTransferHeader sets key to value in h, removing the caller's value under any case of key. +// setTransferHeader sets key to value in h, replacing the caller's value under +// any case of key but keeping the name the caller wrote. func setTransferHeader(h Header, key, value string) { + name := headerNameFold(h, key) deleteHeaderFold(h, key) - h.Set(key, value) + h[name] = []string{value} +} + +// headerNameFold returns the name under which h has key, if h has it under +// exactly one case, and otherwise the canonical form of key. +func headerNameFold(h Header, key string) string { + name := "" + for k := range h { + if strings.EqualFold(k, key) { + if name != "" { + return CanonicalHeaderKey(key) + } + name = k + } + } + if name == "" { + return CanonicalHeaderKey(key) + } + return name } // deleteHeaderFold deletes key from h under any case.