diff --git a/CHANGELOG.md b/CHANGELOG.md index 0317579..fa1394e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.4] - 2026-08-20 + +### Added + +- `nhttp_h1:encode_response/2` takes `t:nhttp_h1:enc_opts/0`. + `#{content_length => omit}` suppresses the automatic `Content-Length` + field. A server that answers a `CONNECT` request with a 2xx status uses + it, because RFC 9110 Section 8.6 forbids the field there and the response + map carries no request method + +### Fixed + +- `nhttp_h1:encode_response/1` emits `Content-Length: 0` on a response with + an empty body. The call omitted the field before, so a client on a + persistent connection read the next response as content +- `nhttp_h1:encode_response/1` emits no `Content-Length` at a 1xx, 204, or + 304 status (RFC 9110 Section 8.6). At 304 the field is valid only at the + length that a 200 response carries, which the encoder cannot compute, so a + caller that knows the value supplies it in the header list + ## [1.0.3] - 2026-08-10 ### Added diff --git a/src/nhttp_h1.erl b/src/nhttp_h1.erl index 06089b4..74566ce 100644 --- a/src/nhttp_h1.erl +++ b/src/nhttp_h1.erl @@ -110,6 +110,7 @@ pattern applies to chunked requests. encode_last_chunk/0, encode_request/1, encode_response/1, + encode_response/2, encode_response_head/3 ]). @@ -128,6 +129,7 @@ pattern applies to chunked requests. body_mode/0, body_stream/0, chunked_st/0, + enc_opts/0, opts/0, parse_error/0, parse_result/1, @@ -158,6 +160,21 @@ pattern applies to chunked requests. | until_close | none. +-doc """ +Encoder options for `encode_response/2`. + +`content_length` selects how the encoder frames a response: + +- `auto` (the default) adds `Content-Length` when the header list carries + neither `content-length` nor `transfer-encoding`, and the status permits + the field. +- `omit` suppresses the automatic field at any status. A server that + answers a `CONNECT` request with a 2xx status uses it, because RFC 9110 + Section 8.6 forbids the field there and the response map carries no + request method. +""". +-type enc_opts() :: #{content_length => auto | omit}. + -type opts() :: #{ max_header_size => pos_integer(), max_headers_count => pos_integer(), @@ -597,7 +614,8 @@ encode_request(#{method := Method, path := Path} = Req) -> Version = maps:get(version, Req, http1_1), Headers = maps:get(headers, Req, []), Body = maps:get(body, Req, <<>>), - FinalHeaders = maybe_add_content_length(Headers, Body), + Len = iolist_size(Body), + FinalHeaders = maybe_add_content_length(Headers, Len, Len > 0), [ nhttp_lib:encode_method(Method), <<" ">>, @@ -610,14 +628,42 @@ encode_request(#{method := Method, path := Path} = Req) -> Body ]. --doc "Encode an HTTP/1.1 response to iolist.". +-doc """ +Encode an HTTP/1.1 response to iolist. + +Equivalent to `encode_response(Resp, #{})`. The encoder adds +`Content-Length` when the header list carries neither `content-length` nor +`transfer-encoding`, including a `Content-Length: 0` on an empty body. + +The encoder adds no `Content-Length` at a 1xx, 204, or 304 status. RFC 9110 +Section 8.6 forbids the field at 1xx and 204. It permits the field at 304 +only at the length that a 200 response would have carried, which this +encoder cannot compute, so a caller that knows the value supplies it in the +header list. + +A 2xx response to a `CONNECT` request also carries no `Content-Length`. The +response map holds no request method, so that case needs +`encode_response/2` with `#{content_length => omit}`. +""". -spec encode_response(resp()) -> iolist(). -encode_response(#{status := Status} = Resp) -> +encode_response(Resp) -> + encode_response(Resp, #{}). + +-doc """ +Encode an HTTP/1.1 response to iolist under the given encoder options. + +See `encode_response/1` for the framing rules and `t:enc_opts/0` for the +options. +""". +-spec encode_response(resp(), enc_opts()) -> iolist(). +encode_response(#{status := Status} = Resp, EncOpts) -> Version = maps:get(version, Resp, http1_1), Reason = maps:get(reason, Resp, <<>>), Headers = maps:get(headers, Resp, []), Body = maps:get(body, Resp, <<>>), - FinalHeaders = maybe_add_content_length(Headers, Body), + FinalHeaders = maybe_add_content_length( + Headers, iolist_size(Body), allows_content_length(Status, EncOpts) + ), [ encode_version(Version), <<" ">>, @@ -1024,23 +1070,34 @@ is_token_chars(<>) -> is_valid_chunk_ext_tail(<<>>) -> true; is_valid_chunk_ext_tail(Bin) -> skip_bws_to_semi(Bin). --spec maybe_add_content_length(nhttp_lib:headers(), iodata()) -> nhttp_lib:headers(). -maybe_add_content_length(Headers, Body) -> - case iolist_size(Body) of - 0 -> +-spec maybe_add_content_length(nhttp_lib:headers(), non_neg_integer(), boolean()) -> + nhttp_lib:headers(). +maybe_add_content_length(Headers, _Len, false) -> + Headers; +maybe_add_content_length(Headers, Len, true) -> + case + nhttp_headers:has(<<"content-length">>, Headers) orelse + nhttp_headers:has(<<"transfer-encoding">>, Headers) + of + true -> Headers; - Len -> - case - nhttp_headers:has(<<"content-length">>, Headers) orelse - nhttp_headers:has(<<"transfer-encoding">>, Headers) - of - true -> - Headers; - false -> - [{<<"content-length">>, integer_to_binary(Len)} | Headers] - end + false -> + [{<<"content-length">>, integer_to_binary(Len)} | Headers] end. +-spec allows_content_length(nhttp_lib:status(), enc_opts()) -> boolean(). +allows_content_length(Status, EncOpts) -> + case maps:get(content_length, EncOpts, auto) of + auto -> not forbids_content_length(Status); + omit -> false + end. + +-spec forbids_content_length(nhttp_lib:status()) -> boolean(). +forbids_content_length(Status) when Status >= 100, Status =< 199 -> true; +forbids_content_length(204) -> true; +forbids_content_length(304) -> true; +forbids_content_length(_Status) -> false. + -spec parse_chunk_body_after_size(binary(), non_neg_integer(), non_neg_integer(), binary()) -> {ok, binary(), pos_integer()} | {final, pos_integer()} diff --git a/src/nhttp_lib.app.src b/src/nhttp_lib.app.src index e8ab456..e343be3 100644 --- a/src/nhttp_lib.app.src +++ b/src/nhttp_lib.app.src @@ -1,6 +1,6 @@ {application, nhttp_lib, [ {description, "HTTP protocol primitives for Erlang/OTP 27+ (HTTP/1.1, HTTP/2, HTTP/3, QPACK)"}, - {vsn, "1.0.3"}, + {vsn, "1.0.4"}, {registered, []}, {applications, [ kernel, diff --git a/test/compliance/nhttp_h1_rfc9110_SUITE.erl b/test/compliance/nhttp_h1_rfc9110_SUITE.erl index 1c47dbd..97fa307 100644 --- a/test/compliance/nhttp_h1_rfc9110_SUITE.erl +++ b/test/compliance/nhttp_h1_rfc9110_SUITE.erl @@ -34,6 +34,11 @@ groups() -> ]}, {section_8_content_length, [parallel], [ no_content_length_1xx_204, + no_content_length_304, + content_length_zero_on_empty_body, + caller_content_length_survives_on_204, + no_content_length_with_transfer_encoding, + content_length_omit_opt_out, reject_malformed_content_length ]}, {section_9_methods, [parallel], [ @@ -106,24 +111,88 @@ reject_or_replace_invalid_chars_in_field_value(_Config) -> %%% Section 8 - Content-Length %%%----------------------------------------------------------------------------- +%% RFC 9110 Section 8.6: "A server MUST NOT send a Content-Length header +%% field in any response with a status code of 1xx (Informational) or 204 +%% (No Content)." no_content_length_1xx_204(_Config) -> - Resp100 = #{ - status => 100, - reason => <<"Continue">>, - headers => [] - }, - Io100 = nhttp_h1:encode_response(Resp100), - Encoded100 = iolist_to_binary(Io100), - ?assertEqual(nomatch, binary:match(Encoded100, <<"Content-Length">>)), + lists:foreach( + fun({Status, Reason}) -> + Resp = #{status => Status, reason => Reason, headers => []}, + Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)), + ?assertEqual(false, has_content_length(Encoded)) + end, + [ + {100, <<"Continue">>}, + {101, <<"Switching Protocols">>}, + {199, <<"Informational">>}, + {204, <<"No Content">>} + ] + ). + +%% RFC 9110 Section 8.6: a 304 permits Content-Length only at the length a +%% 200 response would have carried, which the encoder cannot compute. +no_content_length_304(_Config) -> + Resp = #{status => 304, reason => <<"Not Modified">>, headers => []}, + Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)), + ?assertEqual(false, has_content_length(Encoded)). + +%% RFC 9110 Section 8.6: "in the absence of Transfer-Encoding, an origin +%% server SHOULD send a Content-Length header field when the content size is +%% known prior to sending the complete header section." +content_length_zero_on_empty_body(_Config) -> + NoBodyKey = #{status => 200, reason => <<"OK">>, headers => []}, + ?assertEqual( + {true, <<"0">>}, + content_length_value(iolist_to_binary(nhttp_h1:encode_response(NoBodyKey))) + ), + + EmptyBody = NoBodyKey#{body => <<>>}, + ?assertEqual( + {true, <<"0">>}, + content_length_value(iolist_to_binary(nhttp_h1:encode_response(EmptyBody))) + ), + + NotFound = #{status => 404, reason => <<"Not Found">>, headers => [], body => <<>>}, + ?assertEqual( + {true, <<"0">>}, + content_length_value(iolist_to_binary(nhttp_h1:encode_response(NotFound))) + ). - Resp204 = #{ +caller_content_length_survives_on_204(_Config) -> + Resp = #{ status => 204, reason => <<"No Content">>, - headers => [] + headers => [{<<"content-length">>, <<"42">>}] + }, + Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)), + ?assertEqual({true, <<"42">>}, content_length_value(Encoded)), + ?assertEqual(1, count_content_length(Encoded)). + +no_content_length_with_transfer_encoding(_Config) -> + Resp = #{ + status => 200, + reason => <<"OK">>, + headers => [{<<"transfer-encoding">>, <<"chunked">>}], + body => <<>> }, - Io204 = nhttp_h1:encode_response(Resp204), - Encoded204 = iolist_to_binary(Io204), - ?assertEqual(nomatch, binary:match(Encoded204, <<"Content-Length">>)). + Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp)), + ?assertEqual(false, has_content_length(Encoded)). + +%% RFC 9110 Section 8.6: "A server MUST NOT send a Content-Length header +%% field in any 2xx (Successful) response to a CONNECT request." +content_length_omit_opt_out(_Config) -> + Resp = #{status => 200, reason => <<"Connection Established">>, headers => []}, + Encoded = iolist_to_binary(nhttp_h1:encode_response(Resp, #{content_length => omit})), + ?assertEqual(false, has_content_length(Encoded)), + + WithBody = Resp#{body => <<"hello">>}, + EncodedWithBody = iolist_to_binary( + nhttp_h1:encode_response(WithBody, #{content_length => omit}) + ), + ?assertEqual(false, has_content_length(EncodedWithBody)), + + EncodedAuto = iolist_to_binary(nhttp_h1:encode_response(Resp, #{content_length => auto})), + ?assertEqual({true, <<"0">>}, content_length_value(EncodedAuto)). reject_malformed_content_length(_Config) -> Req1 = << @@ -158,16 +227,18 @@ reject_malformed_content_length(_Config) -> %%% Section 9 - Methods %%%----------------------------------------------------------------------------- +%% RFC 9110 Section 8.6: a 2xx response to CONNECT carries no Content-Length. +%% The status alone does not identify the case, so the caller opts out. no_body_headers_2xx_connect(_Config) -> Resp = #{ status => 200, reason => <<"Connection Established">>, headers => [] }, - Io = nhttp_h1:encode_response(Resp), + Io = nhttp_h1:encode_response(Resp, #{content_length => omit}), Encoded = iolist_to_binary(Io), - ?assertEqual(nomatch, binary:match(Encoded, <<"Content-Length">>)), - ?assertEqual(nomatch, binary:match(Encoded, <<"Transfer-Encoding">>)). + ?assertEqual(false, has_content_length(Encoded)), + ?assertEqual(false, lists:keymember(<<"transfer-encoding">>, 1, encoded_headers(Encoded))). client_ignore_body_headers_connect(_Config) -> Resp = << @@ -215,6 +286,31 @@ no_body_in_304(_Config) -> %%% Helpers %%%----------------------------------------------------------------------------- +-spec encoded_headers(binary()) -> nhttp_lib:headers(). +encoded_headers(Encoded) -> + [_StatusLine | Lines] = binary:split(Encoded, <<"\r\n">>, [global]), + [ + {string:lowercase(Name), string:trim(Value, leading, " ")} + || Line <- Lines, + Line =/= <<>>, + [Name, Value] <- [binary:split(Line, <<":">>)] + ]. + +-spec has_content_length(binary()) -> boolean(). +has_content_length(Encoded) -> + lists:keymember(<<"content-length">>, 1, encoded_headers(Encoded)). + +-spec content_length_value(binary()) -> {true, binary()} | false. +content_length_value(Encoded) -> + case lists:keyfind(<<"content-length">>, 1, encoded_headers(Encoded)) of + {_, Value} -> {true, Value}; + false -> false + end. + +-spec count_content_length(binary()) -> non_neg_integer(). +count_content_length(Encoded) -> + length([V || {<<"content-length">>, V} <- encoded_headers(Encoded)]). + -spec find_header(binary(), nhttp_lib:headers()) -> {ok, binary()} | error. find_header(Name, Headers) -> LowerName = string:lowercase(Name), diff --git a/test/nhttp_h1_SUITE.erl b/test/nhttp_h1_SUITE.erl index c92b444..4be9f1a 100644 --- a/test/nhttp_h1_SUITE.erl +++ b/test/nhttp_h1_SUITE.erl @@ -533,7 +533,8 @@ encode_response_no_body(_Config) -> }, IOList = nhttp_h1:encode_response(Resp), Bin = iolist_to_binary(IOList), - ?assertMatch(<<"HTTP/1.1 204 No Content\r\n", _/binary>>, Bin). + ?assertMatch(<<"HTTP/1.1 204 No Content\r\n", _/binary>>, Bin), + ?assertEqual(nomatch, re:run(Bin, <<"content-length">>, [caseless])). encode_response_head(_Config) -> IOList = nhttp_h1:encode_response_head( diff --git a/test/property_test/nhttp_h1_props.erl b/test/property_test/nhttp_h1_props.erl index d2a81ae..d4bd812 100644 --- a/test/property_test/nhttp_h1_props.erl +++ b/test/property_test/nhttp_h1_props.erl @@ -130,10 +130,18 @@ h1_resp_gen() -> status => Status, reason => Reason, headers => Headers, - body => Body + body => body_for_status(Status, Body) } ). +-spec body_for_status(nhttp_lib:status(), binary()) -> binary(). +body_for_status(Status, _Body) when + Status >= 100, Status =< 199; Status =:= 204; Status =:= 304 +-> + <<>>; +body_for_status(_Status, Body) -> + Body. + -spec method_gen() -> triq_dom:domain(). method_gen() -> oneof([get, post, put, delete, head, options, patch]).