From 5348c83b0914e89bb48761c55c9d0fdbae09d43c Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Thu, 16 Apr 2026 19:59:37 +0200 Subject: [PATCH 01/12] ahttp_client.erl: Add chunked transfer encoding Parse `Transfer-Encoding: chunked` response bodies so callers can consume responses from servers that stream without a `Content-Length` header. The parser now emits `trailer_header` and `trailer_header_continuation` responses, and surfaces malformed or unsupported encodings as `{error, {parser, Reason}}`. Signed-off-by: Davide Bettio --- CHANGELOG.md | 1 + libs/avm_network/src/ahttp_client.erl | 180 +++++++++++-- tests/libs/eavmlib/test_ahttp_client.erl | 326 +++++++++++++++++++++++ 3 files changed, 480 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9474069e9c..e34a3bf244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added named variable debugging support in DWARF when modules are compiled with `beam_debug_info` - Added more reset reasons and ensured `esp:reset_reason/0` doesn't return `undefined` - Added I2C and SPI APIs to stm32 platform +- Added `Transfer-Encoding: chunked` response support to `ahttp_client`, including HTTP trailers ### Changed - Updated network type db() to dbm() to reflect the actual representation of the type diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index e506cd717b..9e7bad01c2 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -23,16 +23,26 @@ -export_type([connection/0, error_tuple/0, backend/0]). --define(DEFAULT_WANTED_HEADERS, [<<"Content-Length">>]). +-define(DEFAULT_WANTED_HEADERS, [<<"Content-Length">>, <<"Transfer-Encoding">>]). -type maybe_binary() :: binary() | undefined. -type maybe_integer() :: integer() | undefined. -type maybe_ref() :: reference() | undefined. --type maybe_parsing_state() :: headers | body | done | undefined. +-type maybe_parsing_state() :: + headers + | body + | chunked_size + | chunked_body + | chunked_crlf + | chunked_trailers + | done + | undefined. +-type body_encoding() :: chunked | undefined. -record(parser_state, { state :: maybe_parsing_state(), acc :: maybe_binary(), + body_encoding :: body_encoding(), remaining_body_bytes :: maybe_integer(), last_header :: maybe_binary() | ignore, wanted_headers :: [binary()] @@ -65,6 +75,9 @@ -type status_response() :: {status, reference(), 0..999}. -type header_response() :: {header, reference(), {binary(), binary()}}. -type header_continuation_response() :: {header_continuation, reference(), {binary(), binary()}}. +-type trailer_header_response() :: {trailer_header, reference(), {binary(), binary()}}. +-type trailer_header_continuation_response() :: + {trailer_header_continuation, reference(), {binary(), binary()}}. -type data_response() :: {data, reference(), binary()}. -type done_response() :: {done, reference()}. @@ -72,10 +85,12 @@ status_response() | header_response() | header_continuation_response() + | trailer_header_response() + | trailer_header_continuation_response() | data_response() | done_response(). --type error_tuple() :: {error, {backend(), term()}}. +-type error_tuple() :: {error, {backend(), term()}} | {error, {parser, term()}}. %%----------------------------------------------------------------------------- %% @param Protocol the protocol, either http or https @@ -246,22 +261,25 @@ transform_headers([{Name, Value} | Tail]) -> {ok, connection(), [response()]} | {ok, connection(), closed} | unknown | error_tuple(). stream(#http_client{socket = {gen_tcp, TSocket}, parser = Parser} = Conn, {tcp, TSocket, Chunk}) -> - {ok, UpdatedParser, Parsed} = feed_parser(Parser, Chunk), - Responses = make_responses(Parsed, Conn#http_client.ref, []), - {ok, Conn#http_client{parser = UpdatedParser}, Responses}; + dispatch_feed(Conn, Parser, Chunk); stream(#http_client{socket = {gen_tcp, TSocket}} = Conn, {tcp_closed, TSocket}) -> {ok, Conn, closed}; stream(#http_client{socket = {ssl, SSLSocket}, parser = Parser} = Conn, {ssl, SSLSocket, Chunk}) -> - {ok, UpdatedParser, Parsed} = feed_parser(Parser, Chunk), - Responses = make_responses(Parsed, Conn#http_client.ref, []), - {ok, Conn#http_client{parser = UpdatedParser}, Responses}; + dispatch_feed(Conn, Parser, Chunk); stream(_Conn, _Other) -> unknown. stream_data(#http_client{parser = Parser} = Conn, Chunk) -> - {ok, UpdatedParser, Parsed} = feed_parser(Parser, Chunk), - Responses = make_responses(Parsed, Conn#http_client.ref, []), - {ok, Conn#http_client{parser = UpdatedParser}, Responses}. + dispatch_feed(Conn, Parser, Chunk). + +dispatch_feed(Conn, Parser, Chunk) -> + case feed_parser(Parser, Chunk) of + {ok, UpdatedParser, Parsed} -> + Responses = make_responses(Parsed, Conn#http_client.ref, []), + {ok, Conn#http_client{parser = UpdatedParser}, Responses}; + {error, _} = Error -> + Error + end. make_responses([], _Ref, Acc) -> Acc; @@ -272,6 +290,8 @@ make_responses([{Tag, Value} | Tail], Ref, Acc) -> feed_parser(#parser_state{state = body} = Parser, Chunk) -> consume_bytes(append_chunk(Parser, Chunk), []); +feed_parser(#parser_state{state = chunked_body} = Parser, Chunk) -> + consume_chunk_data(append_chunk(Parser, Chunk), []); feed_parser(Parser, Chunk) -> consume_lines(append_chunk(Parser, Chunk), []). @@ -289,6 +309,38 @@ consume_bytes(#parser_state{acc = Chunk} = Parser, ParsedAcc) when is_binary(Chu ), {ok, UpdatedParser, Parsed}. +consume_chunk_data(#parser_state{acc = undefined} = Parser, ParsedAcc) -> + {ok, Parser, ParsedAcc}; +consume_chunk_data( + #parser_state{acc = Chunk, remaining_body_bytes = N} = Parser, ParsedAcc +) when is_binary(Chunk) andalso byte_size(Chunk) < N -> + NewN = N - byte_size(Chunk), + UpdatedParser = Parser#parser_state{acc = undefined, remaining_body_bytes = NewN}, + {ok, UpdatedParser, [{data, Chunk} | ParsedAcc]}; +consume_chunk_data( + #parser_state{acc = Chunk, remaining_body_bytes = N} = Parser, ParsedAcc +) when is_binary(Chunk) -> + <> = Chunk, + Transitioned = Parser#parser_state{state = chunked_crlf, remaining_body_bytes = 0}, + Replaced = replace_chunk(Transitioned, Rest), + consume_lines(Replaced, [{data, Data} | ParsedAcc]). + +parse_chunk_size(Line) -> + [HexBin | _Ext] = binary:split(Line, <<";">>), + LTrim = trim_left_spaces(HexBin, 0), + case LTrim of + <<>> -> + error; + _ -> + Trimmed = trim_right_spaces(LTrim, byte_size(LTrim)), + try binary_to_integer(Trimmed, 16) of + N when N >= 0 -> {ok, N}; + _ -> error + catch + error:_ -> error + end + end. + consume_lines(#parser_state{acc = undefined} = Parser, ParsedAcc) -> {ok, Parser, ParsedAcc}; consume_lines(Parser, ParsedAcc) -> @@ -300,12 +352,14 @@ consume_lines(Parser, ParsedAcc) -> case parse_line(ReplacedAccParser, Line) of {consume_bytes, UpdatedParser} -> consume_bytes(UpdatedParser, ParsedAcc); + {consume_chunk_data, UpdatedParser} -> + consume_chunk_data(UpdatedParser, ParsedAcc); {ok, UpdatedParser} -> consume_lines(UpdatedParser, ParsedAcc); {ok, UpdatedParser, Found} -> consume_lines(UpdatedParser, [Found | ParsedAcc]); - {error, UpdatedParser, NotParsed} -> - {error, UpdatedParser, ParsedAcc, NotParsed} + {error, _UpdatedParser, Reason} -> + {error, {parser, Reason}} end end. @@ -329,6 +383,13 @@ parse_line( ) -> StatusCode = binary_to_integer(C), {ok, Parser#parser_state{state = headers}, {status, StatusCode}}; +parse_line( + #parser_state{state = headers, body_encoding = chunked, remaining_body_bytes = N} = Parser, + <<>> +) when is_integer(N) -> + {error, Parser, {content_length_with_transfer_encoding, N}}; +parse_line(#parser_state{state = headers, body_encoding = chunked} = Parser, <<>>) -> + {ok, Parser#parser_state{state = chunked_size, last_header = undefined}}; parse_line(#parser_state{state = headers} = Parser, <<>>) -> {consume_bytes, Parser#parser_state{state = body}}; parse_line( @@ -346,23 +407,88 @@ parse_line(#parser_state{state = headers, wanted_headers = WantedHeaders} = Pars {ok, Name, Value} -> LTrimmedValue = trim_left_spaces(Value, 0), TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), - UpdatedParser = - case Name of - % this is safe since match_header uses same casing as in WantedHeaders - <<"Content-Length">> -> - RemainingLen = binary_to_integer(TrimmedValue), - Parser#parser_state{remaining_body_bytes = RemainingLen}; - _ -> - Parser - end, - {ok, UpdatedParser#parser_state{last_header = Name}, {header, {Name, TrimmedValue}}}; + % this is safe since match_header uses same casing as in WantedHeaders + case apply_header_semantics(Name, TrimmedValue, Parser) of + {ok, UpdatedParser} -> + {ok, UpdatedParser#parser_state{last_header = Name}, + {header, {Name, TrimmedValue}}}; + {error, Reason} -> + {error, Parser, Reason} + end; ignore -> - {ok, Parser#parser_state{last_header = ignore}}; + {ok, Parser#parser_state{last_header = ignore}} + end; +parse_line(#parser_state{state = chunked_size} = Parser, Line) -> + case parse_chunk_size(Line) of + {ok, 0} -> + {ok, Parser#parser_state{state = chunked_trailers, last_header = undefined}}; + {ok, N} -> + {consume_chunk_data, Parser#parser_state{ + state = chunked_body, remaining_body_bytes = N + }}; error -> - {error, Parser, HeaderLine} + {error, Parser, {invalid_chunk_size, Line}} + end; +parse_line(#parser_state{state = chunked_crlf} = Parser, <<>>) -> + {ok, Parser#parser_state{state = chunked_size}}; +parse_line(#parser_state{state = chunked_crlf} = Parser, Line) -> + {error, Parser, {expected_chunk_crlf, Line}}; +parse_line(#parser_state{state = chunked_trailers} = Parser, <<>>) -> + {ok, Parser#parser_state{state = done}, done}; +parse_line( + #parser_state{state = chunked_trailers, last_header = ignore} = Parser, + <> +) when C == $\s orelse C == $\t -> + {ok, Parser}; +parse_line( + #parser_state{state = chunked_trailers, last_header = LastH} = Parser, + <> +) when is_binary(LastH) andalso (C == $\s orelse C == $\t) -> + LTrimmedValue = trim_left_spaces(MultiLine, 0), + TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), + {ok, Parser, {trailer_header_continuation, {LastH, TrimmedValue}}}; +parse_line( + #parser_state{state = chunked_trailers, wanted_headers = WantedHeaders} = Parser, HeaderLine +) -> + case match_header(WantedHeaders, HeaderLine) of + {ok, Name, Value} -> + case is_forbidden_trailer_field(Name) of + true -> + {ok, Parser#parser_state{last_header = ignore}}; + false -> + LTrimmedValue = trim_left_spaces(Value, 0), + TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), + {ok, Parser#parser_state{last_header = Name}, + {trailer_header, {Name, TrimmedValue}}} + end; + ignore -> + {ok, Parser#parser_state{last_header = ignore}} end; parse_line(Parser, Any) -> - {error, Parser, Any}. + {error, Parser, {invalid_line, Any}}. + +apply_header_semantics(<<"Content-Length">>, Value, Parser) -> + {ok, Parser#parser_state{remaining_body_bytes = binary_to_integer(Value)}}; +apply_header_semantics(<<"Transfer-Encoding">>, Value, Parser) -> + case te_is_chunked(Value) of + true -> {ok, Parser#parser_state{body_encoding = chunked}}; + false -> {error, {unsupported_transfer_encoding, Value}} + end; +apply_header_semantics(_Name, _Value, Parser) -> + {ok, Parser}. + +%% Only bare "chunked" is accepted; stacked codings like "gzip, chunked" (valid per RFC 9112 §6.1) +%% would deliver undecoded bytes since this client has no gzip/compress/deflate decoder. +te_is_chunked(Value) -> + case byte_size(Value) =:= byte_size(<<"chunked">>) of + true -> icmp(Value, <<"chunked">>); + false -> false + end. + +%% Framing-affecting fields filtered from trailers (subset of RFC 9110 §6.5.1). +is_forbidden_trailer_field(<<"Content-Length">>) -> true; +is_forbidden_trailer_field(<<"Transfer-Encoding">>) -> true; +is_forbidden_trailer_field(_) -> false. trim_left_spaces(Bin, Count) -> case Bin of diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 6ef1adb675..9fea3213cc 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -26,6 +26,17 @@ test() -> ok = test_active(), ok = test_passive_socket(), ok = test_active_socket(), + ok = test_chunked_passive(), + ok = test_chunked_active(), + ok = test_chunked_split_across_segments(), + ok = test_chunked_extension(), + ok = test_chunked_trailer(), + ok = test_bad_transfer_encoding(), + ok = test_bad_transfer_encoding_stacked(), + ok = test_bad_transfer_encoding_bad_order(), + ok = test_content_length_and_transfer_encoding(), + ok = test_transfer_encoding_before_content_length(), + ok = test_chunked_trailer_framing_filtered(), ok. test_passive() -> @@ -158,3 +169,318 @@ parse_responses( _Expected ) -> Resp#{done => true}. + +test_chunked_passive() -> + Response = build_chunked_response([], [<<"Hello">>, <<"World">>], []), + {ServerPid, Port} = start_chunked_server([Response]), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"HelloWorld">>, done := true} = Acc, + wait_server(ServerPid), + ok. + +test_chunked_active() -> + Response = build_chunked_response([], [<<"ping">>], []), + {ServerPid, Port} = start_chunked_server([Response]), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, true}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_active(Conn2, #{}), + #{status := 200, body := <<"ping">>, done := true} = Acc, + wait_server(ServerPid), + ok. + +test_chunked_split_across_segments() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "a\r\n12345" + >>, + <<"67890\r\n0\r\n\r\n">> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"1234567890">>, done := true} = Acc, + wait_server(ServerPid), + ok. + +test_chunked_extension() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "5;foo=bar\r\nHello\r\n" + "0\r\n\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"Hello">>, done := true} = Acc, + wait_server(ServerPid), + ok. + +test_chunked_trailer() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "5\r\nHello\r\n" + "0\r\nX-Digest: sha256-abc\r\n\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [ + {active, false}, {parse_headers, [<<"X-Digest">>]} + ]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{ + status := 200, + body := <<"Hello">>, + done := true, + trailers := [{<<"X-Digest">>, <<"sha256-abc">>}] + } = Acc, + wait_server(ServerPid), + ok. + +test_bad_transfer_encoding() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: gzip\r\n\r\n" + "whatever" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {unsupported_transfer_encoding, <<"gzip">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_bad_transfer_encoding_stacked() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: gzip, chunked\r\n\r\n" + "whatever" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {unsupported_transfer_encoding, <<"gzip, chunked">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_bad_transfer_encoding_bad_order() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked, gzip\r\n\r\n" + "whatever" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {unsupported_transfer_encoding, <<"chunked, gzip">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_content_length_and_transfer_encoding() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "5\r\nHello\r\n0\r\n\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {content_length_with_transfer_encoding, 5}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_transfer_encoding_before_content_length() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "Content-Length: 5\r\n\r\n" + "5\r\nHello\r\n0\r\n\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {content_length_with_transfer_encoding, 5}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_chunked_trailer_framing_filtered() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "5\r\nHello\r\n" + "0\r\n" + "Content-Length: 99999\r\n" + "Transfer-Encoding: identity\r\n" + "\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"Hello">>, done := true} = Acc, + [] = maps:get(trailers, Acc, []), + wait_server(ServerPid), + ok. + +build_chunked_response(ExtraHeaders, Chunks, Trailers) -> + HeaderLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- ExtraHeaders], + ChunkLines = [ + [integer_to_binary(byte_size(C), 16), <<"\r\n">>, C, <<"\r\n">>] + || C <- Chunks + ], + TrailerLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- Trailers], + iolist_to_binary([ + <<"HTTP/1.1 200 OK\r\n">>, + <<"Transfer-Encoding: chunked\r\n">>, + HeaderLines, + <<"\r\n">>, + ChunkLines, + <<"0\r\n">>, + TrailerLines, + <<"\r\n">> + ]). + +start_chunked_server(Segments) -> + Parent = self(), + Pid = spawn(fun() -> chunked_server_loop(Parent, Segments) end), + receive + {server_port, Pid, Port} -> {Pid, Port} + after 5000 -> + exit(Pid, kill), + error(server_start_timeout) + end. + +chunked_server_loop(Parent, Segments) -> + {ok, LSocket} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(LSocket), + Parent ! {server_port, self(), Port}, + {ok, Socket} = gen_tcp:accept(LSocket, 5000), + ok = drain_request_headers(Socket), + send_segments(Socket, Segments), + gen_tcp:close(Socket), + gen_tcp:close(LSocket). + +drain_request_headers(Socket) -> + drain_request_headers(Socket, <<>>). + +drain_request_headers(Socket, Acc) -> + case gen_tcp:recv(Socket, 0, 2000) of + {ok, Data} -> + NewAcc = <>, + case binary:match(NewAcc, <<"\r\n\r\n">>) of + nomatch -> drain_request_headers(Socket, NewAcc); + _ -> ok + end; + {error, _} -> + ok + end. + +send_segments(_Socket, []) -> + ok; +send_segments(Socket, [Segment | Rest]) -> + ok = gen_tcp:send(Socket, Segment), + case Rest of + [] -> ok; + _ -> receive + after 50 -> ok + end + end, + send_segments(Socket, Rest). + +wait_server(Pid) -> + Ref = monitor(process, Pid), + receive + {'DOWN', Ref, process, Pid, _} -> ok + after 5000 -> + demonitor(Ref, [flush]), + exit(Pid, kill), + error(server_did_not_exit) + end. + +loop_collect_passive(Conn, Acc) -> + case ahttp_client:recv(Conn, 0) of + {ok, UpdatedConn, Responses} -> + NewAcc = accumulate(Responses, Acc), + case maps:is_key(done, NewAcc) of + true -> + ahttp_client:close(UpdatedConn), + NewAcc; + false -> + loop_collect_passive(UpdatedConn, NewAcc) + end + end. + +loop_collect_active(Conn, Acc) -> + receive + Msg -> + case ahttp_client:stream(Conn, Msg) of + {ok, _Conn, closed} -> + Acc; + {ok, UpdatedConn, Responses} -> + NewAcc = accumulate(Responses, Acc), + case maps:is_key(done, NewAcc) of + true -> + ahttp_client:close(UpdatedConn), + NewAcc; + false -> + loop_collect_active(UpdatedConn, NewAcc) + end; + unknown -> + loop_collect_active(Conn, Acc) + end + after 5000 -> + error(no_response_timeout) + end. + +accumulate([], Acc) -> + Acc; +accumulate([{status, _, Code} | T], Acc) -> + accumulate(T, Acc#{status => Code}); +accumulate([{header, _, _KV} | T], Acc) -> + accumulate(T, Acc#{has_headers => true}); +accumulate([{header_continuation, _, _KV} | T], Acc) -> + accumulate(T, Acc); +accumulate([{trailer_header, _, KV} | T], Acc) -> + Ts = maps:get(trailers, Acc, []), + accumulate(T, Acc#{trailers => [KV | Ts]}); +accumulate([{trailer_header_continuation, _, _KV} | T], Acc) -> + accumulate(T, Acc); +accumulate([{data, _, Data} | T], Acc) -> + Body = maps:get(body, Acc, <<>>), + accumulate(T, Acc#{body => <>}); +accumulate([{done, _} | T], Acc) -> + accumulate(T, Acc#{done => true}). From f531015da438a8e28d505347c74a48611bb4a855 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 20 Apr 2026 17:17:05 +0000 Subject: [PATCH 02/12] ahttp_client.erl: Fix crash on bad Content-Length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-numeric or negative Content-Length values previously crashed the parser process with badarg from binary_to_integer/1. Surface them as a parser error so callers can recover; per RFC 9112 §6.3 the field value is 1*DIGIT, anything else is malformed framing. Signed-off-by: Davide Bettio --- CHANGELOG.md | 1 + libs/avm_network/src/ahttp_client.erl | 9 +++++- tests/libs/eavmlib/test_ahttp_client.erl | 36 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34a3bf244..b413f72144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop using deprecated `term_from_int32` on ESP32 platform - Fixed improper cast of ESP32 `event_data` for `WIFI_EVENT_AP_STA(DIS)CONNECTED` events - `erlang:system_info(system_architecture)` now reports normalized `arch-vendor-os` strings +- Fixed `ahttp_client` crash on non-numeric or negative `Content-Length` values ## [0.7.0-alpha.1] - 2026-04-06 diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 9e7bad01c2..4fa5161226 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -468,7 +468,14 @@ parse_line(Parser, Any) -> {error, Parser, {invalid_line, Any}}. apply_header_semantics(<<"Content-Length">>, Value, Parser) -> - {ok, Parser#parser_state{remaining_body_bytes = binary_to_integer(Value)}}; + try binary_to_integer(Value) of + N when N >= 0 -> + {ok, Parser#parser_state{remaining_body_bytes = N}}; + _ -> + {error, {invalid_content_length, Value}} + catch + error:badarg -> {error, {invalid_content_length, Value}} + end; apply_header_semantics(<<"Transfer-Encoding">>, Value, Parser) -> case te_is_chunked(Value) of true -> {ok, Parser#parser_state{body_encoding = chunked}}; diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 9fea3213cc..3421b54ba6 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -37,6 +37,8 @@ test() -> ok = test_content_length_and_transfer_encoding(), ok = test_transfer_encoding_before_content_length(), ok = test_chunked_trailer_framing_filtered(), + ok = test_bad_content_length_non_numeric(), + ok = test_bad_content_length_negative(), ok. test_passive() -> @@ -356,6 +358,40 @@ test_chunked_trailer_framing_filtered() -> wait_server(ServerPid), ok. +test_bad_content_length_non_numeric() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: abc\r\n\r\n" + "whatever" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {invalid_content_length, <<"abc">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_bad_content_length_negative() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: -1\r\n\r\n" + "whatever" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {invalid_content_length, <<"-1">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + build_chunked_response(ExtraHeaders, Chunks, Trailers) -> HeaderLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- ExtraHeaders], ChunkLines = [ From d2aafd4bfeb64d85ac52436ea8d5d27a9f197d0b Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 20 Apr 2026 17:17:49 +0000 Subject: [PATCH 03/12] ahttp_client.erl: Fix empty header value crash A header whose value is empty or all-whitespace (e.g. "X-Foo: \r\n") reached trim_right_spaces/2 with Count = 0 and raised badarg on a negative-length binary pattern, crashing the parser process. Signed-off-by: Davide Bettio --- CHANGELOG.md | 1 + libs/avm_network/src/ahttp_client.erl | 2 ++ tests/libs/eavmlib/test_ahttp_client.erl | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b413f72144..fae573678a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed improper cast of ESP32 `event_data` for `WIFI_EVENT_AP_STA(DIS)CONNECTED` events - `erlang:system_info(system_architecture)` now reports normalized `arch-vendor-os` strings - Fixed `ahttp_client` crash on non-numeric or negative `Content-Length` values +- Fixed `ahttp_client` crash on headers with empty or all-whitespace values ## [0.7.0-alpha.1] - 2026-04-06 diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 4fa5161226..6f3686c588 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -505,6 +505,8 @@ trim_left_spaces(Bin, Count) -> NoLeftSpaces end. +trim_right_spaces(_Bin, 0) -> + <<>>; trim_right_spaces(Bin, Count) -> Len = Count - 1, case Bin of diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 3421b54ba6..d30349bcc0 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -39,6 +39,7 @@ test() -> ok = test_chunked_trailer_framing_filtered(), ok = test_bad_content_length_non_numeric(), ok = test_bad_content_length_negative(), + ok = test_empty_header_value(), ok. test_passive() -> @@ -392,6 +393,27 @@ test_bad_content_length_negative() -> wait_server(ServerPid), ok. +test_empty_header_value() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "X-Empty:\r\n" + "X-Spaces: \r\n" + "Content-Length: 5\r\n" + "\r\n" + "Hello" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [ + {active, false}, {parse_headers, [<<"X-Empty">>, <<"X-Spaces">>]} + ]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"Hello">>, done := true} = Acc, + wait_server(ServerPid), + ok. + build_chunked_response(ExtraHeaders, Chunks, Trailers) -> HeaderLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- ExtraHeaders], ChunkLines = [ From 32cb7d369041081016c025bfd7e6b954d8bf6c08 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 20 Apr 2026 17:21:05 +0000 Subject: [PATCH 04/12] ahttp_client.erl: Fix silent truncation on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premature socket close used to be indistinguishable from a normal end of response: stream/2 returned ok-closed, recv/2 returned the backend-closed tuple, and ssl_closed had no handler at all. RFC 9112 §6.3 requires incomplete messages to be treated as an error; return {error, {parser, incomplete_response}} when the parser is still inside headers, a Content-Length body with bytes remaining, or any chunked state. Close after a complete response and HTTP/1.0 close-delimited bodies stay on the normal path. Signed-off-by: Davide Bettio --- CHANGELOG.md | 2 ++ libs/avm_network/src/ahttp_client.erl | 31 ++++++++++++++++++++---- tests/libs/eavmlib/test_ahttp_client.erl | 23 ++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae573678a..e2748de4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated network type db() to dbm() to reflect the actual representation of the type - Use ES6 modules for emscripten port, using .mjs suffix +- `ahttp_client` now returns `{error, {parser, incomplete_response}}` when a socket closes mid-response + (previously silently reported `closed`); `ssl_closed` messages are also handled ### Fixed - Stop using deprecated `term_from_int32` on STM32 platform diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 6f3686c588..c436d1ebf0 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -262,13 +262,27 @@ transform_headers([{Name, Value} | Tail]) -> stream(#http_client{socket = {gen_tcp, TSocket}, parser = Parser} = Conn, {tcp, TSocket, Chunk}) -> dispatch_feed(Conn, Parser, Chunk); -stream(#http_client{socket = {gen_tcp, TSocket}} = Conn, {tcp_closed, TSocket}) -> - {ok, Conn, closed}; +stream(#http_client{socket = {gen_tcp, TSocket}, parser = Parser} = Conn, {tcp_closed, TSocket}) -> + case is_parser_truncated(Parser) of + false -> {ok, Conn, closed}; + true -> {error, {parser, incomplete_response}} + end; stream(#http_client{socket = {ssl, SSLSocket}, parser = Parser} = Conn, {ssl, SSLSocket, Chunk}) -> dispatch_feed(Conn, Parser, Chunk); +stream(#http_client{socket = {ssl, SSLSocket}, parser = Parser} = Conn, {ssl_closed, SSLSocket}) -> + case is_parser_truncated(Parser) of + false -> {ok, Conn, closed}; + true -> {error, {parser, incomplete_response}} + end; stream(_Conn, _Other) -> unknown. +is_parser_truncated(undefined) -> false; +is_parser_truncated(#parser_state{state = done}) -> false; +is_parser_truncated(#parser_state{state = undefined}) -> false; +is_parser_truncated(#parser_state{state = body, remaining_body_bytes = undefined}) -> false; +is_parser_truncated(_) -> true. + stream_data(#http_client{parser = Parser} = Conn, Chunk) -> dispatch_feed(Conn, Parser, Chunk). @@ -597,10 +611,17 @@ stream_request_body(#http_client{socket = Socket, ref = Ref} = Conn, Ref, BodyCh -spec recv(Conn :: connection(), Len :: non_neg_integer()) -> {ok, connection(), [response()]} | error_tuple(). -recv(#http_client{socket = {SocketType, _}} = Conn, Len) -> +recv(#http_client{socket = {SocketType, _}, parser = Parser} = Conn, Len) -> case socket_recv(Conn, Len) of - {ok, Data} -> stream_data(Conn, Data); - {error, Reason} -> {error, {SocketType, Reason}} + {ok, Data} -> + stream_data(Conn, Data); + {error, closed} -> + case is_parser_truncated(Parser) of + false -> {error, {SocketType, closed}}; + true -> {error, {parser, incomplete_response}} + end; + {error, Reason} -> + {error, {SocketType, Reason}} end. socket_recv(#http_client{socket = {gen_tcp, TCPSocket}}, Len) -> diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index d30349bcc0..e91aa89782 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -40,6 +40,7 @@ test() -> ok = test_bad_content_length_non_numeric(), ok = test_bad_content_length_negative(), ok = test_empty_header_value(), + ok = test_chunked_truncated(), ok. test_passive() -> @@ -414,6 +415,28 @@ test_empty_header_value() -> wait_server(ServerPid), ok. +test_chunked_truncated() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "a\r\n12345" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, incomplete_response}} = drain_until_error(Conn2), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +drain_until_error(Conn) -> + case ahttp_client:recv(Conn, 0) of + {ok, UpdatedConn, _Responses} -> drain_until_error(UpdatedConn); + {error, _} = Error -> Error + end. + build_chunked_response(ExtraHeaders, Chunks, Trailers) -> HeaderLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- ExtraHeaders], ChunkLines = [ From 2c8d7d593bec864c209962e30d2990b3afb5e246 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 20 Apr 2026 22:23:20 +0000 Subject: [PATCH 05/12] ahttp_client.erl: Fix Content-Length conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conflicting Content-Length values were silently accepted with the last one winning, a classic request-smuggling primitive. Per RFC 9112 §6.3 such a message is unrecoverable; accept duplicates only when values match, otherwise return {error, {parser, {conflicting_content_length, V}}}. Signed-off-by: Davide Bettio --- CHANGELOG.md | 3 ++ libs/avm_network/src/ahttp_client.erl | 9 +++++- tests/libs/eavmlib/test_ahttp_client.erl | 37 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2748de4ee..4bab93e5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use ES6 modules for emscripten port, using .mjs suffix - `ahttp_client` now returns `{error, {parser, incomplete_response}}` when a socket closes mid-response (previously silently reported `closed`); `ssl_closed` messages are also handled +- `ahttp_client` now returns `{error, {parser, {conflicting_content_length, V}}}` when a response + carries differing `Content-Length` values (previously silently accepted the last one), + per RFC 9112 §6.3 ### Fixed - Stop using deprecated `term_from_int32` on STM32 platform diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index c436d1ebf0..be06802860 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -484,7 +484,14 @@ parse_line(Parser, Any) -> apply_header_semantics(<<"Content-Length">>, Value, Parser) -> try binary_to_integer(Value) of N when N >= 0 -> - {ok, Parser#parser_state{remaining_body_bytes = N}}; + case Parser#parser_state.remaining_body_bytes of + undefined -> + {ok, Parser#parser_state{remaining_body_bytes = N}}; + N -> + {ok, Parser}; + _Other -> + {error, {conflicting_content_length, Value}} + end; _ -> {error, {invalid_content_length, Value}} catch diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index e91aa89782..f235513475 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -39,6 +39,8 @@ test() -> ok = test_chunked_trailer_framing_filtered(), ok = test_bad_content_length_non_numeric(), ok = test_bad_content_length_negative(), + ok = test_duplicate_content_length_same_value(), + ok = test_conflicting_content_length(), ok = test_empty_header_value(), ok = test_chunked_truncated(), ok. @@ -394,6 +396,41 @@ test_bad_content_length_negative() -> wait_server(ServerPid), ok. +test_duplicate_content_length_same_value() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "Content-Length: 5\r\n\r\n" + "Hello" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"Hello">>, done := true} = Acc, + wait_server(ServerPid), + ok. + +test_conflicting_content_length() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "Content-Length: 10\r\n\r\n" + "Hello" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {conflicting_content_length, <<"10">>}}} = + ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + test_empty_header_value() -> Segments = [ << From 7b971bd979d7f79ef33f51dd95e873cd156ebc8e Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 20 Apr 2026 22:35:01 +0000 Subject: [PATCH 06/12] ahttp_client.erl: Fix hang on empty response body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Content-Length: 0 response left the parser in body state forever: the transition to done only fired from consume_bytes after processing bytes, which never ran for an empty body. Passive callers hung and truncation detection misflagged a legitimate close as incomplete_response. Transition to done at end-of-headers when remaining_body_bytes is 0, matching the message-completion rule in RFC 9112 §6.3. Signed-off-by: Davide Bettio --- CHANGELOG.md | 3 +++ libs/avm_network/src/ahttp_client.erl | 2 ++ tests/libs/eavmlib/test_ahttp_client.erl | 17 +++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bab93e5c7..c626e49398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ahttp_client` now returns `{error, {parser, {conflicting_content_length, V}}}` when a response carries differing `Content-Length` values (previously silently accepted the last one), per RFC 9112 §6.3 +- `ahttp_client` now emits a `done` event for responses with `Content-Length: 0` (previously + the parser stayed in `body` state forever, hanging passive callers and misflagging legitimate + close as `incomplete_response`) ### Fixed - Stop using deprecated `term_from_int32` on STM32 platform diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index be06802860..908ab23463 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -404,6 +404,8 @@ parse_line( {error, Parser, {content_length_with_transfer_encoding, N}}; parse_line(#parser_state{state = headers, body_encoding = chunked} = Parser, <<>>) -> {ok, Parser#parser_state{state = chunked_size, last_header = undefined}}; +parse_line(#parser_state{state = headers, remaining_body_bytes = 0} = Parser, <<>>) -> + {ok, Parser#parser_state{state = done, last_header = undefined}, done}; parse_line(#parser_state{state = headers} = Parser, <<>>) -> {consume_bytes, Parser#parser_state{state = body}}; parse_line( diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index f235513475..fef488c6ac 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -41,6 +41,7 @@ test() -> ok = test_bad_content_length_negative(), ok = test_duplicate_content_length_same_value(), ok = test_conflicting_content_length(), + ok = test_content_length_zero(), ok = test_empty_header_value(), ok = test_chunked_truncated(), ok. @@ -431,6 +432,22 @@ test_conflicting_content_length() -> wait_server(ServerPid), ok. +test_content_length_zero() -> + Segments = [ + << + "HTTP/1.1 204 No Content\r\n" + "Content-Length: 0\r\n\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 204, done := true} = Acc, + <<>> = maps:get(body, Acc, <<>>), + wait_server(ServerPid), + ok. + test_empty_header_value() -> Segments = [ << From e216290ba6aa2201d22869c26d2ed0cfd72501c1 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 07:19:15 +0000 Subject: [PATCH 07/12] ahttp_client.erl: Fix Content-Length overrun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consume_bytes/2 emitted the entire buffered chunk without bounding it by remaining_body_bytes, handing bytes past Content-Length to the caller as body and stalling the parser. Per RFC 9112 §6.3, bytes beyond Content-Length are not part of the current message: cap emission at the promised count, discard the rest (this client does not pipeline), and transition to done. Signed-off-by: Davide Bettio --- CHANGELOG.md | 3 +++ libs/avm_network/src/ahttp_client.erl | 6 ++++++ tests/libs/eavmlib/test_ahttp_client.erl | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c626e49398..be9a99cb9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ahttp_client` now emits a `done` event for responses with `Content-Length: 0` (previously the parser stayed in `body` state forever, hanging passive callers and misflagging legitimate close as `incomplete_response`) +- `ahttp_client` now discards bytes past `Content-Length` and transitions to `done` (previously + emitted the excess as body data and stalled the parser when the socket delivered more than + the promised byte count) ### Fixed - Stop using deprecated `term_from_int32` on STM32 platform diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 908ab23463..1a877b9067 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -311,6 +311,12 @@ feed_parser(Parser, Chunk) -> consume_bytes(#parser_state{acc = undefined} = Parser, ParsedAcc) -> {ok, Parser, ParsedAcc}; +consume_bytes( + #parser_state{acc = Chunk, remaining_body_bytes = N} = Parser, ParsedAcc +) when is_binary(Chunk) andalso is_integer(N) andalso N > 0 andalso byte_size(Chunk) >= N -> + <> = Chunk, + UpdatedParser = Parser#parser_state{state = done, acc = undefined, remaining_body_bytes = 0}, + {ok, UpdatedParser, [done, {data, Data} | ParsedAcc]}; consume_bytes(#parser_state{acc = Chunk} = Parser, ParsedAcc) when is_binary(Chunk) -> ReplacedAccParser = replace_chunk(Parser, undefined), NewRemBodyBytes = maybe_decrement( diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index fef488c6ac..269c89fca7 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -42,6 +42,7 @@ test() -> ok = test_duplicate_content_length_same_value(), ok = test_conflicting_content_length(), ok = test_content_length_zero(), + ok = test_content_length_overrun(), ok = test_empty_header_value(), ok = test_chunked_truncated(), ok. @@ -448,6 +449,22 @@ test_content_length_zero() -> wait_server(ServerPid), ok. +test_content_length_overrun() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n\r\n" + "HelloExtra" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + Acc = loop_collect_passive(Conn2, #{}), + #{status := 200, body := <<"Hello">>, done := true} = Acc, + wait_server(ServerPid), + ok. + test_empty_header_value() -> Segments = [ << From 77f7c3811e4588335f1f7689eaf89cd20647f80c Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 07:39:12 +0000 Subject: [PATCH 08/12] ahttp_client.erl: Document data binary retention {data, Ref, Binary} responses carry sub-binaries of the socket receive buffer, not copies. Piping chunks straight into the next parser is the common case and fine; callers that retain a chunk beyond the current processing step should binary:copy/1 first so the underlying receive buffer can be released. Signed-off-by: Davide Bettio --- libs/avm_network/src/ahttp_client.erl | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 1a877b9067..1ac1b7b061 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -255,6 +255,13 @@ transform_headers([{Name, Value} | Tail]) -> %% %% Since the response might span multiple socket messages, `stream/2' may be called %% multiple times. Each time the latest `UpdatedConn' must be used. +%% +%% Binaries in `{data, Ref, Binary}' responses are sub-binaries of the socket +%% receive buffer; the client never copies. This is the right default for the +%% common case of piping chunks straight into another parser (e.g. a JSON +%% decoder) that consumes and drops them before the next call. Callers who +%% retain a chunk beyond the current processing step should call +%% `binary:copy/1' on it first so the underlying receive buffer can be released. %% @end %%----------------------------------------------------------------------------- -spec stream(Conn :: connection(), Msg :: socket_message()) -> From 30ca4db63ace1c5e49c28842cc5c0c8162869c59 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 08:00:45 +0000 Subject: [PATCH 09/12] ahttp_client.erl: Remove obs-fold support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9112 §5.2 deprecated line folding and different recipients merge continuations differently (with or without the required SP octet), making obs-fold a known header-smuggling primitive. Reject folded header and trailer lines with {error, {parser, {deprecated_obs_fold, Line}}} and drop the header_continuation and trailer_header_continuation response events, no in-tree caller relied on them, and the API grammar is materially smaller without them. Signed-off-by: Davide Bettio --- CHANGELOG.md | 5 +++ libs/avm_network/src/ahttp_client.erl | 35 +++++------------- tests/libs/eavmlib/test_ahttp_client.erl | 45 +++++++++++++++++++++--- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be9a99cb9b..af8242cbc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 emitted the excess as body data and stalled the parser when the socket delivered more than the promised byte count) +### Removed +- Removed `ahttp_client` support for obsolete line folding (RFC 9112 §5.2); folded header and + trailer lines now return `{error, {parser, deprecated_obs_fold}}`, and the + `header_continuation` / `trailer_header_continuation` response events are no longer emitted + ### Fixed - Stop using deprecated `term_from_int32` on STM32 platform - Stop using deprecated `term_from_int32` on RP2 platform diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 1ac1b7b061..384e1645b0 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -74,19 +74,14 @@ -type status_response() :: {status, reference(), 0..999}. -type header_response() :: {header, reference(), {binary(), binary()}}. --type header_continuation_response() :: {header_continuation, reference(), {binary(), binary()}}. -type trailer_header_response() :: {trailer_header, reference(), {binary(), binary()}}. --type trailer_header_continuation_response() :: - {trailer_header_continuation, reference(), {binary(), binary()}}. -type data_response() :: {data, reference(), binary()}. -type done_response() :: {done, reference()}. -type response() :: status_response() | header_response() - | header_continuation_response() | trailer_header_response() - | trailer_header_continuation_response() | data_response() | done_response(). @@ -421,16 +416,10 @@ parse_line(#parser_state{state = headers, remaining_body_bytes = 0} = Parser, << {ok, Parser#parser_state{state = done, last_header = undefined}, done}; parse_line(#parser_state{state = headers} = Parser, <<>>) -> {consume_bytes, Parser#parser_state{state = body}}; -parse_line( - #parser_state{state = headers, last_header = ignore} = Parser, <> -) when C == $\s orelse C == $\t -> - {ok, Parser}; -parse_line( - #parser_state{state = headers, last_header = LastH} = Parser, <> -) when is_binary(LastH) andalso (C == $\s orelse C == $\t) -> - LTrimmedValue = trim_left_spaces(MultiLine, 0), - TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), - {ok, Parser, {header_continuation, {LastH, TrimmedValue}}}; +parse_line(#parser_state{state = headers} = Parser, <> = Line) when + C == $\s orelse C == $\t +-> + {error, Parser, {deprecated_obs_fold, Line}}; parse_line(#parser_state{state = headers, wanted_headers = WantedHeaders} = Parser, HeaderLine) -> case match_header(WantedHeaders, HeaderLine) of {ok, Name, Value} -> @@ -464,18 +453,10 @@ parse_line(#parser_state{state = chunked_crlf} = Parser, Line) -> {error, Parser, {expected_chunk_crlf, Line}}; parse_line(#parser_state{state = chunked_trailers} = Parser, <<>>) -> {ok, Parser#parser_state{state = done}, done}; -parse_line( - #parser_state{state = chunked_trailers, last_header = ignore} = Parser, - <> -) when C == $\s orelse C == $\t -> - {ok, Parser}; -parse_line( - #parser_state{state = chunked_trailers, last_header = LastH} = Parser, - <> -) when is_binary(LastH) andalso (C == $\s orelse C == $\t) -> - LTrimmedValue = trim_left_spaces(MultiLine, 0), - TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), - {ok, Parser, {trailer_header_continuation, {LastH, TrimmedValue}}}; +parse_line(#parser_state{state = chunked_trailers} = Parser, <> = Line) when + C == $\s orelse C == $\t +-> + {error, Parser, {deprecated_obs_fold, Line}}; parse_line( #parser_state{state = chunked_trailers, wanted_headers = WantedHeaders} = Parser, HeaderLine ) -> diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 269c89fca7..561f68ee3d 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -37,6 +37,8 @@ test() -> ok = test_content_length_and_transfer_encoding(), ok = test_transfer_encoding_before_content_length(), ok = test_chunked_trailer_framing_filtered(), + ok = test_obs_fold_rejected(), + ok = test_obs_fold_rejected_trailer(), ok = test_bad_content_length_non_numeric(), ok = test_bad_content_length_negative(), ok = test_duplicate_content_length_same_value(), @@ -343,6 +345,45 @@ test_transfer_encoding_before_content_length() -> wait_server(ServerPid), ok. +test_obs_fold_rejected() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "X-Folded: first-part\r\n" + " continuation\r\n" + "Content-Length: 5\r\n\r\n" + "Hello" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {deprecated_obs_fold, <<" continuation">>}}} = ahttp_client:recv(Conn2, 0), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + +test_obs_fold_rejected_trailer() -> + Segments = [ + << + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n\r\n" + "5\r\nHello\r\n" + "0\r\n" + "X-Trailer: value\r\n" + "\t continuation\r\n" + "\r\n" + >> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {parser, {deprecated_obs_fold, <<"\t continuation">>}}} = + drain_until_error(Conn2), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + test_chunked_trailer_framing_filtered() -> Segments = [ << @@ -624,13 +665,9 @@ accumulate([{status, _, Code} | T], Acc) -> accumulate(T, Acc#{status => Code}); accumulate([{header, _, _KV} | T], Acc) -> accumulate(T, Acc#{has_headers => true}); -accumulate([{header_continuation, _, _KV} | T], Acc) -> - accumulate(T, Acc); accumulate([{trailer_header, _, KV} | T], Acc) -> Ts = maps:get(trailers, Acc, []), accumulate(T, Acc#{trailers => [KV | Ts]}); -accumulate([{trailer_header_continuation, _, _KV} | T], Acc) -> - accumulate(T, Acc); accumulate([{data, _, Data} | T], Acc) -> Body = maps:get(body, Acc, <<>>), accumulate(T, Acc#{body => <>}); From 8916a7cbfc5ed69f4fc896632f08507c2016d446 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 08:16:51 +0000 Subject: [PATCH 10/12] ahttp_client.erl: Add 16 KiB parsed line cap consume_lines/2 appended incoming data to acc without bound, so a hostile peer could pressure memory by streaming a long status, header or chunk-size line that never closed with CRLF. Cap each parsed line at 16 KiB; a longer line returns {error, {parser, {line_too_long, Prefix}}}. Not RFC-mandated; matches the defaults used by nginx and Apache. Signed-off-by: Davide Bettio --- CHANGELOG.md | 4 ++++ libs/avm_network/src/ahttp_client.erl | 14 ++++++++++++++ tests/libs/eavmlib/test_ahttp_client.erl | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af8242cbc0..6dfad7498d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ahttp_client` now discards bytes past `Content-Length` and transitions to `done` (previously emitted the excess as body data and stalled the parser when the socket delivered more than the promised byte count) +- `ahttp_client` now caps parsed status, header and chunk-size lines at 16 KiB (`?MAX_LINE_SIZE`); + longer lines return `{error, {parser, {line_too_long, Prefix}}}` with the first 128 bytes of + the offending line. Callers whose upstream servers emit unusually large headers must account + for this limit ### Removed - Removed `ahttp_client` support for obsolete line folding (RFC 9112 §5.2); folded header and diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 384e1645b0..691eb78f82 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -24,6 +24,7 @@ -export_type([connection/0, error_tuple/0, backend/0]). -define(DEFAULT_WANTED_HEADERS, [<<"Content-Length">>, <<"Transfer-Encoding">>]). +-define(MAX_LINE_SIZE, 16384). -type maybe_binary() :: binary() | undefined. -type maybe_integer() :: integer() | undefined. @@ -257,6 +258,11 @@ transform_headers([{Name, Value} | Tail]) -> %% decoder) that consumes and drops them before the next call. Callers who %% retain a chunk beyond the current processing step should call %% `binary:copy/1' on it first so the underlying receive buffer can be released. +%% +%% Individual response headers are limited to 16 KiB. A response whose +%% header exceeds this size returns `{error, {parser, line_too_long}}'. +%% The same size limit also applies to the HTTP status line and, for +%% chunked transfer-encoded responses, to each chunk-size line. %% @end %%----------------------------------------------------------------------------- -spec stream(Conn :: connection(), Msg :: socket_message()) -> @@ -363,12 +369,20 @@ parse_chunk_size(Line) -> end end. +%% TODO: pair MAX_LINE_SIZE with a recv/stream timeout — slow-drip peers can +%% still tie up a connection under the memory cap. consume_lines(#parser_state{acc = undefined} = Parser, ParsedAcc) -> {ok, Parser, ParsedAcc}; consume_lines(Parser, ParsedAcc) -> case binary:split(Parser#parser_state.acc, <<"\r\n">>) of + [NotTerminatedLine] when byte_size(NotTerminatedLine) > ?MAX_LINE_SIZE -> + <> = NotTerminatedLine, + {error, {parser, {line_too_long, Prefix}}}; [_NotTerminatedLine] -> {ok, Parser, ParsedAcc}; + [Line, _Rest] when byte_size(Line) > ?MAX_LINE_SIZE -> + <> = Line, + {error, {parser, {line_too_long, Prefix}}}; [Line, Rest] -> ReplacedAccParser = replace_chunk(Parser, Rest), case parse_line(ReplacedAccParser, Line) of diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 561f68ee3d..0e8da18db7 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -39,6 +39,7 @@ test() -> ok = test_chunked_trailer_framing_filtered(), ok = test_obs_fold_rejected(), ok = test_obs_fold_rejected_trailer(), + ok = test_header_line_too_long(), ok = test_bad_content_length_non_numeric(), ok = test_bad_content_length_negative(), ok = test_duplicate_content_length_same_value(), @@ -345,6 +346,22 @@ test_transfer_encoding_before_content_length() -> wait_server(ServerPid), ok. +test_header_line_too_long() -> + %% One header line well over the 16 KiB cap; CRLF never arrives inside the limit. + LongValue = binary:copy(<<$A>>, 20000), + Segments = [ + <<"HTTP/1.1 200 OK\r\n">>, + <<"X-Huge: ", LongValue/binary, "\r\n">> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + ExpectedPrefix = <<"X-Huge: ", (binary:copy(<<$A>>, 120))/binary>>, + {error, {parser, {line_too_long, ExpectedPrefix}}} = drain_until_error(Conn2), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + test_obs_fold_rejected() -> Segments = [ << From 08d450f19d88c145afe0fb16cfa60dcd5b61bfc7 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 10:06:11 +0000 Subject: [PATCH 11/12] ahttp_client.erl: Refactor OWS trim helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Count-based trim_left_spaces/2 and trim_right_spaces/2 with the idiomatic recursive byte-wise pattern. Rename to match the HTTP spec term (RFC 9110 §5.6.3: OWS = *( SP / HTAB )). Same observable behavior; smaller and faster on BEAM through match- context reuse. Stays byte-wise rather than calling string:trim/3 because HTTP field values are not UTF-8 (RFC 9110 §5.5 admits obs-text = %x80-FF) and OTP string functions require valid unicode:chardata. Signed-off-by: Davide Bettio --- libs/avm_network/src/ahttp_client.erl | 40 +++++++++++++-------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 691eb78f82..7a065f4c46 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -355,12 +355,12 @@ consume_chunk_data( parse_chunk_size(Line) -> [HexBin | _Ext] = binary:split(Line, <<";">>), - LTrim = trim_left_spaces(HexBin, 0), + LTrim = trim_ows_left(HexBin), case LTrim of <<>> -> error; _ -> - Trimmed = trim_right_spaces(LTrim, byte_size(LTrim)), + Trimmed = trim_ows_right(LTrim), try binary_to_integer(Trimmed, 16) of N when N >= 0 -> {ok, N}; _ -> error @@ -437,8 +437,8 @@ parse_line(#parser_state{state = headers} = Parser, <> = Line) parse_line(#parser_state{state = headers, wanted_headers = WantedHeaders} = Parser, HeaderLine) -> case match_header(WantedHeaders, HeaderLine) of {ok, Name, Value} -> - LTrimmedValue = trim_left_spaces(Value, 0), - TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), + LTrimmedValue = trim_ows_left(Value), + TrimmedValue = trim_ows_right(LTrimmedValue), % this is safe since match_header uses same casing as in WantedHeaders case apply_header_semantics(Name, TrimmedValue, Parser) of {ok, UpdatedParser} -> @@ -480,8 +480,8 @@ parse_line( true -> {ok, Parser#parser_state{last_header = ignore}}; false -> - LTrimmedValue = trim_left_spaces(Value, 0), - TrimmedValue = trim_right_spaces(LTrimmedValue, byte_size(LTrimmedValue)), + LTrimmedValue = trim_ows_left(Value), + TrimmedValue = trim_ows_right(LTrimmedValue), {ok, Parser#parser_state{last_header = Name}, {trailer_header, {Name, TrimmedValue}}} end; @@ -528,23 +528,21 @@ is_forbidden_trailer_field(<<"Content-Length">>) -> true; is_forbidden_trailer_field(<<"Transfer-Encoding">>) -> true; is_forbidden_trailer_field(_) -> false. -trim_left_spaces(Bin, Count) -> - case Bin of - <<_Bin:Count/binary, C, _Rest/binary>> when C == $\s orelse C == $\t -> - trim_left_spaces(Bin, Count + 1); - <<_Bin:Count/binary, NoLeftSpaces/binary>> -> - NoLeftSpaces - end. +%% Trim HTTP OWS (RFC 9110 §5.6.3: OWS = *( SP / HTAB )) from the left. +trim_ows_left(<<$\s, Rest/binary>>) -> trim_ows_left(Rest); +trim_ows_left(<<$\t, Rest/binary>>) -> trim_ows_left(Rest); +trim_ows_left(Bin) -> Bin. -trim_right_spaces(_Bin, 0) -> +trim_ows_right(Bin) -> + trim_ows_right(Bin, byte_size(Bin)). +trim_ows_right(_Bin, 0) -> <<>>; -trim_right_spaces(Bin, Count) -> - Len = Count - 1, - case Bin of - <<_LBin:Len/binary, C, _TrimmedSpaces/binary>> when C == $\s orelse C == $\t -> - trim_right_spaces(Bin, Count - 1); - <> -> - LBin +trim_ows_right(Bin, Len) -> + case binary:at(Bin, Len - 1) of + C when C =:= $\s orelse C =:= $\t -> + trim_ows_right(Bin, Len - 1); + _ -> + binary:part(Bin, 0, Len) end. maybe_decrement(undefined, _B) -> From bd2b9562bcb890fd9c38d4e586836b8ee040cd32 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 21 Apr 2026 10:40:00 +0000 Subject: [PATCH 12/12] ahttp_client.erl: Document close return shapes The active-mode stream/2 returns {ok, Conn, closed} on a normal peer close; the passive-mode recv/2 returns {error, {SocketType, closed}} in the same situation. This asymmetry is intentional: active mode surfaces end-of-stream as a distinct ok marker so a receive loop has something to match against, and passive mode inherits the native gen_tcp:recv/2 / ssl:recv/2 contract so callers can keep their case expression a single match on {ok, _, _}. Both paths return {error, {parser, incomplete_response}} when the close is premature. Document the shapes on both functions so the asymmetry is not mistaken for a bug, and pin the behaviour with test_active_close and test_passive_close. Signed-off-by: Davide Bettio --- libs/avm_network/src/ahttp_client.erl | 18 +++++++++++ tests/libs/eavmlib/test_ahttp_client.erl | 39 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/libs/avm_network/src/ahttp_client.erl b/libs/avm_network/src/ahttp_client.erl index 7a065f4c46..ff67eccea4 100644 --- a/libs/avm_network/src/ahttp_client.erl +++ b/libs/avm_network/src/ahttp_client.erl @@ -263,6 +263,15 @@ transform_headers([{Name, Value} | Tail]) -> %% header exceeds this size returns `{error, {parser, line_too_long}}'. %% The same size limit also applies to the HTTP status line and, for %% chunked transfer-encoded responses, to each chunk-size line. +%% +%% When the peer closes the connection after a complete response (or +%% before any request was sent), `stream/2' returns +%% `{ok, Conn, closed}' — a distinct ok marker that callers can use +%% to stop their receive loop. A close that arrives while the parser +%% is still mid-response returns `{error, {parser, incomplete_response}}'. +%% The passive-mode `recv/2' uses a different shape on close +%% (`{error, {SocketType, closed}}') because it inherits the native +%% `gen_tcp:recv/2' / `ssl:recv/2' contract; see `recv/2'. %% @end %%----------------------------------------------------------------------------- -spec stream(Conn :: connection(), Msg :: socket_message()) -> @@ -621,6 +630,15 @@ stream_request_body(#http_client{socket = Socket, ref = Ref} = Conn, Ref, BodyCh %% `{active, false}'. %% %% See also `stream/2' for more information about the responses list. +%% +%% A peer close after a complete response returns +%% `{error, {SocketType, closed}}', matching the native +%% `gen_tcp:recv/2' / `ssl:recv/2' contract unchanged. A close +%% mid-response returns `{error, {parser, incomplete_response}}'. This +%% differs from active-mode `stream/2', which uses +%% `{ok, Conn, closed}' for a normal close — passive mode surfaces +%% close as an error so a straightforward `case recv/2' in the +%% caller stays a single match on `{ok, _, _}'. %% @end %%----------------------------------------------------------------------------- -spec recv(Conn :: connection(), Len :: non_neg_integer()) -> diff --git a/tests/libs/eavmlib/test_ahttp_client.erl b/tests/libs/eavmlib/test_ahttp_client.erl index 0e8da18db7..c191745064 100644 --- a/tests/libs/eavmlib/test_ahttp_client.erl +++ b/tests/libs/eavmlib/test_ahttp_client.erl @@ -48,6 +48,8 @@ test() -> ok = test_content_length_overrun(), ok = test_empty_header_value(), ok = test_chunked_truncated(), + ok = test_active_close(), + ok = test_passive_close(), ok. test_passive() -> @@ -566,6 +568,43 @@ drain_until_error(Conn) -> {error, _} = Error -> Error end. +test_active_close() -> + %% Active mode surfaces a normal peer close as {ok, Conn, closed}. + Segments = [ + <<"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello">> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, true}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {ok, _Conn3, closed} = active_wait_for_close(Conn2), + wait_server(ServerPid), + ok. + +active_wait_for_close(Conn) -> + receive + Msg -> + case ahttp_client:stream(Conn, Msg) of + {ok, _, closed} = R -> R; + {ok, UpdatedConn, _Responses} -> active_wait_for_close(UpdatedConn); + unknown -> active_wait_for_close(Conn) + end + after 5000 -> + error(no_close_within_timeout) + end. + +test_passive_close() -> + %% Passive mode surfaces a normal peer close as {error, {SocketType, closed}}. + Segments = [ + <<"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello">> + ], + {ServerPid, Port} = start_chunked_server(Segments), + {ok, Conn} = ahttp_client:connect(http, "localhost", Port, [{active, false}]), + {ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined), + {error, {gen_tcp, closed}} = drain_until_error(Conn2), + ahttp_client:close(Conn2), + wait_server(ServerPid), + ok. + build_chunked_response(ExtraHeaders, Chunks, Trailers) -> HeaderLines = [[N, <<": ">>, V, <<"\r\n">>] || {N, V} <- ExtraHeaders], ChunkLines = [