Skip to content

Commit 79bc0f7

Browse files
authored
Stream metadata.config decoding in chunks and increase max size to 1024KB (#181)
* Stream metadata.config decoding in chunks Decode metadata.config by feeding the binary through safe_erl_term:tokens/2 in 4KB slices, parsing each dot-terminated form as it completes, instead of materializing the whole file as a char list before tokenizing. Peak transient decode memory drops from roughly 17x the binary size to binary + ~64KB. This makes it safe to bump MAX_METADATA_SIZE from 128KB to 1MB. Switches the leex dot rule to end_token so tokens/2 returns one form per call. Atom safety (list_to_existing_atom) and the restricted grammar are preserved. UTF-8 is decoded incrementally with a latin1 fallback restart on invalid bytes. * Address review feedback on chunked metadata decoder - Fix infinite loop on trailing incomplete UTF-8 bytes: when no input bytes remain but the UTF-8 buffer is non-empty, fall back to latin1 rather than spin re-decoding the same buffer. - Drop overly broad try/catch around erl_parse:parse_term/1; the function only returns {ok, _} | {error, _} and shouldn't throw. - Comment in safe_erl_term.xrl explaining why the dot rule emits end_token instead of token. - Cover the new chunked path in decode_metadata_test: large multi-chunk input, UTF-8 char straddling a chunk boundary, latin1 fallback for embedded invalid UTF-8 bytes, the trailing-incomplete- UTF-8 regression case, and 5000 terms across many chunks. * Add metadata_fields option for selective metadata decoding Adds a `metadata_fields` config option (default `all`) that opts callers into reading only specific top-level keys from metadata.config. When a list is given, the decoder switches to a token-by-token streaming parser: forms whose key is not in the list are discarded with only a bracket-depth counter held in state, so peak memory stays bounded by the chunk size plus one token regardless of how large unwanted forms get.
1 parent 9cac2a7 commit 79bc0f7

4 files changed

Lines changed: 382 additions & 30 deletions

File tree

src/hex_core.erl

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@
7171
%%
7272
%% * `docs_tarball_max_uncompressed_size' - Maximum size of uncompressed docs tarball, defaults to
7373
%% `134_217_728' (128 MiB). Set to `infinity' to not enforce the limit.
74+
%%
75+
%% * `metadata_fields' - Either `all' or a list of metadata.config keys (binaries) to read.
76+
%% When set to a list, the metadata decoder streams past unrequested fields without
77+
%% buffering their tokens, which keeps peak memory bounded for packages with very
78+
%% large fields like `<<"files">>'. Defaults to `all'.
7479

7580
-module(hex_core).
7681
-export([default_config/0]).
@@ -111,7 +116,8 @@
111116
tarball_max_size => pos_integer() | infinity,
112117
tarball_max_uncompressed_size => pos_integer() | infinity,
113118
docs_tarball_max_size => pos_integer() | infinity,
114-
docs_tarball_max_uncompressed_size => pos_integer() | infinity
119+
docs_tarball_max_uncompressed_size => pos_integer() | infinity,
120+
metadata_fields => all | [binary()]
115121
}.
116122

117123
-spec default_config() -> config().
@@ -137,5 +143,6 @@ default_config() ->
137143
tarball_max_size => 16 * 1024 * 1024,
138144
tarball_max_uncompressed_size => 128 * 1024 * 1024,
139145
docs_tarball_max_size => 16 * 1024 * 1024,
140-
docs_tarball_max_uncompressed_size => 128 * 1024 * 1024
146+
docs_tarball_max_uncompressed_size => 128 * 1024 * 1024,
147+
metadata_fields => all
141148
}.

src/hex_tarball.erl

Lines changed: 277 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
format_error/1
1111
]).
1212
-ifdef(TEST).
13-
-export([do_decode_metadata/1, gzip/1, normalize_requirements/1]).
13+
-export([do_decode_metadata/1, do_decode_metadata/2, gzip/1, normalize_requirements/1]).
1414
-endif.
1515
-define(VERSION, <<"3">>).
1616
-define(HASH_CHUNK_SIZE, 65536).
1717
-define(MAX_VERSION_SIZE, 32).
1818
-define(MAX_CHECKSUM_SIZE, 128).
19-
-define(MAX_METADATA_SIZE, 128 * 1024).
19+
-define(MAX_METADATA_SIZE, 1024 * 1024).
20+
-define(METADATA_CHUNK_SIZE, 4096).
2021
-define(BUILD_TOOL_FILES, [
2122
{<<"mix.exs">>, <<"mix">>},
2223
{<<"rebar.config">>, <<"rebar3">>},
@@ -546,41 +547,292 @@ check_inner_checksum(#{files := Files} = State) ->
546547
%% @private
547548
decode_metadata({error, _} = Error) ->
548549
Error;
549-
decode_metadata(#{files := #{"metadata.config" := Binary}} = State) when is_binary(Binary) ->
550-
case do_decode_metadata(Binary) of
550+
decode_metadata(#{files := #{"metadata.config" := Binary}, config := Config} = State) when
551+
is_binary(Binary)
552+
->
553+
Fields = maps:get(metadata_fields, Config, all),
554+
case do_decode_metadata(Binary, Fields) of
551555
#{} = Metadata -> maps:put(metadata, normalize_metadata(Metadata), State);
552556
Other -> Other
553557
end.
554558

559+
-ifdef(TEST).
555560
%% @private
556-
do_decode_metadata(Binary) when is_binary(Binary) ->
557-
{ok, String} = characters_to_list(Binary),
561+
do_decode_metadata(Binary) ->
562+
do_decode_metadata(Binary, all).
563+
-endif.
558564

559-
case safe_erl_term:string(String) of
560-
{ok, Tokens, _Line} ->
561-
try
562-
Terms = safe_erl_term:terms(Tokens),
563-
maps:from_list(Terms)
564-
catch
565-
error:function_clause ->
566-
{error, {metadata, invalid_terms}};
567-
error:badarg ->
568-
{error, {metadata, not_key_value}}
565+
%% @private
566+
do_decode_metadata(Binary, all) when is_binary(Binary) ->
567+
case decode_metadata_chunked(utf8, Binary, <<>>, [], "", []) of
568+
latin1_fallback ->
569+
decode_metadata_chunked(latin1, Binary, <<>>, [], "", []);
570+
Other ->
571+
Other
572+
end;
573+
do_decode_metadata(Binary, Fields) when is_binary(Binary), is_list(Fields) ->
574+
case decode_metadata_streaming(utf8, Binary, <<>>, [], "", [], Fields, start) of
575+
latin1_fallback ->
576+
decode_metadata_streaming(latin1, Binary, <<>>, [], "", [], Fields, start);
577+
Other ->
578+
Other
579+
end.
580+
581+
%% @private
582+
%% Streams the metadata.config binary through safe_erl_term:tokens/2 in
583+
%% small chunks so we never materialize the whole binary as a char list.
584+
%% Each accepted dot-terminated form is parsed and accumulated immediately,
585+
%% keeping peak memory at roughly one chunk + one term's tokens + AST.
586+
decode_metadata_chunked(Encoding, Binary, IncTail, Cont, Chars, Acc) ->
587+
case Chars of
588+
[] when Binary =:= <<>>, IncTail =:= <<>> ->
589+
flush_metadata_eof(Cont, Acc);
590+
[] when Binary =:= <<>>, Encoding =:= utf8 ->
591+
%% Trailing bytes that can never form a complete UTF-8 sequence —
592+
%% restart the whole decode in latin1 mode rather than spin.
593+
latin1_fallback;
594+
[] ->
595+
case decode_metadata_chunk(Encoding, Binary, IncTail) of
596+
{ok, NewChars, NewBinary, NewTail} ->
597+
feed_metadata(Encoding, Cont, NewChars, NewBinary, NewTail, Acc);
598+
latin1_fallback ->
599+
latin1_fallback
600+
end;
601+
_ ->
602+
feed_metadata(Encoding, Cont, Chars, Binary, IncTail, Acc)
603+
end.
604+
605+
%% @private
606+
feed_metadata(Encoding, Cont, Chars, Binary, IncTail, Acc) ->
607+
case safe_erl_term:tokens(Cont, Chars) of
608+
{more, NewCont} ->
609+
decode_metadata_chunked(Encoding, Binary, IncTail, NewCont, "", Acc);
610+
{done, {ok, Tokens, _}, RestChars} ->
611+
case parse_metadata_term(Tokens) of
612+
{ok, Term} ->
613+
decode_metadata_chunked(
614+
Encoding, Binary, IncTail, [], normalize_rest_chars(RestChars), [Term | Acc]
615+
);
616+
{error, _} = Err ->
617+
Err
569618
end;
570-
{error, {_Line, safe_erl_term, Reason}, _Line2} ->
619+
{done, {eof, _}, _} ->
620+
finalize_metadata(Acc);
621+
{done, {error, {_, safe_erl_term, Reason}, _}, _} ->
622+
{error, {metadata, Reason}}
623+
end.
624+
625+
%% @private
626+
flush_metadata_eof([], Acc) ->
627+
finalize_metadata(Acc);
628+
flush_metadata_eof(Cont, Acc) ->
629+
case safe_erl_term:tokens(Cont, eof) of
630+
{done, {eof, _}, _} ->
631+
finalize_metadata(Acc);
632+
{done, {ok, _Tokens, _}, _} ->
633+
{error, {metadata, invalid_terms}};
634+
{done, {error, {_, safe_erl_term, Reason}, _}, _} ->
571635
{error, {metadata, Reason}}
572636
end.
573637

574638
%% @private
575-
characters_to_list(Binary) ->
576-
case unicode:characters_to_list(Binary) of
577-
List when is_list(List) ->
578-
{ok, List};
639+
finalize_metadata([]) ->
640+
{error, {metadata, invalid_terms}};
641+
finalize_metadata(Acc) ->
642+
try maps:from_list(lists:reverse(Acc)) of
643+
Map -> Map
644+
catch
645+
error:badarg -> {error, {metadata, not_key_value}}
646+
end.
647+
648+
%% @private
649+
parse_metadata_term(Tokens) ->
650+
case erl_parse:parse_term(Tokens) of
651+
{ok, Term} -> {ok, Term};
652+
{error, _} -> {error, {metadata, invalid_terms}}
653+
end.
654+
655+
%% @private
656+
decode_metadata_chunk(utf8, Binary, IncTail) ->
657+
{Chunk, Rest} = take_metadata_chunk(Binary),
658+
Combined =
659+
case IncTail of
660+
<<>> -> Chunk;
661+
_ -> <<IncTail/binary, Chunk/binary>>
662+
end,
663+
case unicode:characters_to_list(Combined, utf8) of
664+
L when is_list(L) ->
665+
{ok, L, Rest, <<>>};
666+
{incomplete, L, NewTail} ->
667+
{ok, L, Rest, NewTail};
579668
{error, _, _} ->
580-
case unicode:characters_to_list(Binary, latin1) of
581-
List when is_list(List) -> {ok, List};
582-
Other -> Other
583-
end
669+
latin1_fallback
670+
end;
671+
decode_metadata_chunk(latin1, Binary, _IncTail) ->
672+
{Chunk, Rest} = take_metadata_chunk(Binary),
673+
{ok, binary_to_list(Chunk), Rest, <<>>}.
674+
675+
%% @private
676+
take_metadata_chunk(Binary) when byte_size(Binary) > ?METADATA_CHUNK_SIZE ->
677+
<<Chunk:(?METADATA_CHUNK_SIZE)/binary, Rest/binary>> = Binary,
678+
{Chunk, Rest};
679+
take_metadata_chunk(Binary) ->
680+
{Binary, <<>>}.
681+
682+
%% @private
683+
normalize_rest_chars(eof) -> "";
684+
normalize_rest_chars(L) when is_list(L) -> L.
685+
686+
%% @private
687+
%% Streams the metadata.config binary through safe_erl_term:token/2 one token
688+
%% at a time. Forms whose key is in Fields are accumulated and parsed; forms
689+
%% whose key is not in Fields are discarded with only a depth counter held in
690+
%% state, so peak memory stays bounded regardless of the unwanted form's size.
691+
decode_metadata_streaming(Encoding, Binary, IncTail, Cont, Chars, Acc, Fields, State) ->
692+
case Chars of
693+
[] when Binary =:= <<>>, IncTail =:= <<>> ->
694+
flush_metadata_streaming_eof(Cont, Acc, Fields, State);
695+
[] when Binary =:= <<>>, Encoding =:= utf8 ->
696+
latin1_fallback;
697+
[] ->
698+
case decode_metadata_chunk(Encoding, Binary, IncTail) of
699+
{ok, NewChars, NewBinary, NewTail} ->
700+
feed_metadata_streaming(
701+
Encoding, Cont, NewChars, NewBinary, NewTail, Acc, Fields, State
702+
);
703+
latin1_fallback ->
704+
latin1_fallback
705+
end;
706+
_ ->
707+
feed_metadata_streaming(Encoding, Cont, Chars, Binary, IncTail, Acc, Fields, State)
708+
end.
709+
710+
%% @private
711+
feed_metadata_streaming(Encoding, Cont, Chars, Binary, IncTail, Acc, Fields, State) ->
712+
case safe_erl_term:token(Cont, Chars) of
713+
{more, NewCont} ->
714+
decode_metadata_streaming(Encoding, Binary, IncTail, NewCont, "", Acc, Fields, State);
715+
{done, {ok, Token, _}, RestChars} ->
716+
case advance_metadata_state(State, Acc, Fields, Token) of
717+
{next, NewState, NewAcc} ->
718+
decode_metadata_streaming(
719+
Encoding,
720+
Binary,
721+
IncTail,
722+
[],
723+
normalize_rest_chars(RestChars),
724+
NewAcc,
725+
Fields,
726+
NewState
727+
);
728+
{error, _} = Err ->
729+
Err
730+
end;
731+
{done, {eof, _}, _} ->
732+
finalize_metadata_streaming(Acc, State);
733+
{done, {error, {_, safe_erl_term, Reason}, _}, _} ->
734+
{error, {metadata, Reason}}
735+
end.
736+
737+
%% @private
738+
flush_metadata_streaming_eof([], Acc, _Fields, State) ->
739+
finalize_metadata_streaming(Acc, State);
740+
flush_metadata_streaming_eof(Cont, Acc, Fields, State) ->
741+
case safe_erl_term:token(Cont, eof) of
742+
{done, {ok, Token, _}, _} ->
743+
case advance_metadata_state(State, Acc, Fields, Token) of
744+
{next, NewState, NewAcc} ->
745+
flush_metadata_streaming_eof([], NewAcc, Fields, NewState);
746+
{error, _} = Err ->
747+
Err
748+
end;
749+
{done, {eof, _}, _} ->
750+
finalize_metadata_streaming(Acc, State);
751+
{done, {error, {_, safe_erl_term, Reason}, _}, _} ->
752+
{error, {metadata, Reason}}
753+
end.
754+
755+
%% @private
756+
finalize_metadata_streaming(Acc, start) ->
757+
finalize_metadata(Acc);
758+
finalize_metadata_streaming([], between) ->
759+
#{};
760+
finalize_metadata_streaming(Acc, between) ->
761+
finalize_metadata(Acc);
762+
finalize_metadata_streaming(_Acc, _State) ->
763+
{error, {metadata, invalid_terms}}.
764+
765+
%% @private
766+
%% State machine for streaming the metadata.config schema. Forms are required
767+
%% to be `{<<"key">>, value}.` — anything else is rejected as invalid.
768+
%%
769+
%% States: start | between | {after_open, Prefix} | {after_left_binary, Prefix}
770+
%% | {after_key, KeyChars, Prefix} | {after_right_binary, KeyChars, Prefix}
771+
%% | {accumulate, Prefix, Depth} | {skip, Depth}
772+
%%
773+
%% `start` is the initial position; `between` is the position after a form has
774+
%% been completed. Distinguishing them lets empty input return the same
775+
%% invalid_terms error as the non-streaming path while a stream that
776+
%% successfully skipped every form returns an empty map.
777+
advance_metadata_state(Open, Acc, _Fields, {'{', _} = T) when Open =:= start; Open =:= between ->
778+
{next, {after_open, [T]}, Acc};
779+
advance_metadata_state({after_open, Prefix}, Acc, _Fields, {'<<', _} = T) ->
780+
{next, {after_left_binary, [T | Prefix]}, Acc};
781+
advance_metadata_state({after_left_binary, Prefix}, Acc, _Fields, {string, _, KeyChars} = T) ->
782+
{next, {after_key, KeyChars, [T | Prefix]}, Acc};
783+
advance_metadata_state({after_key, KeyChars, Prefix}, Acc, _Fields, {'>>', _} = T) ->
784+
{next, {after_right_binary, KeyChars, [T | Prefix]}, Acc};
785+
advance_metadata_state({after_right_binary, KeyChars, Prefix}, Acc, Fields, {',', _} = T) ->
786+
case extract_metadata_key(KeyChars) of
787+
{ok, Key} ->
788+
case lists:member(Key, Fields) of
789+
true -> {next, {accumulate, [T | Prefix], 1}, Acc};
790+
false -> {next, {skip, 1}, Acc}
791+
end;
792+
error ->
793+
{error, {metadata, not_key_value}}
794+
end;
795+
advance_metadata_state({accumulate, Prefix, 0}, Acc, _Fields, {dot, _} = T) ->
796+
Tokens = lists:reverse([T | Prefix]),
797+
case parse_metadata_term(Tokens) of
798+
{ok, Term} -> {next, between, [Term | Acc]};
799+
{error, _} = Err -> Err
800+
end;
801+
advance_metadata_state({accumulate, _, _}, _Acc, _Fields, {dot, _}) ->
802+
{error, {metadata, invalid_terms}};
803+
advance_metadata_state({accumulate, Prefix, Depth}, Acc, _Fields, {Open, _} = T) when
804+
Open =:= '{'; Open =:= '['
805+
->
806+
{next, {accumulate, [T | Prefix], Depth + 1}, Acc};
807+
advance_metadata_state({accumulate, Prefix, Depth}, Acc, _Fields, {Close, _} = T) when
808+
Close =:= '}'; Close =:= ']'
809+
->
810+
{next, {accumulate, [T | Prefix], Depth - 1}, Acc};
811+
advance_metadata_state({accumulate, Prefix, Depth}, Acc, _Fields, T) ->
812+
{next, {accumulate, [T | Prefix], Depth}, Acc};
813+
advance_metadata_state({skip, 0}, Acc, _Fields, {dot, _}) ->
814+
{next, between, Acc};
815+
advance_metadata_state({skip, _}, _Acc, _Fields, {dot, _}) ->
816+
{error, {metadata, invalid_terms}};
817+
advance_metadata_state({skip, Depth}, Acc, _Fields, {Open, _}) when
818+
Open =:= '{'; Open =:= '['
819+
->
820+
{next, {skip, Depth + 1}, Acc};
821+
advance_metadata_state({skip, Depth}, Acc, _Fields, {Close, _}) when
822+
Close =:= '}'; Close =:= ']'
823+
->
824+
{next, {skip, Depth - 1}, Acc};
825+
advance_metadata_state({skip, Depth}, Acc, _Fields, _Token) ->
826+
{next, {skip, Depth}, Acc};
827+
advance_metadata_state(_State, _Acc, _Fields, _Token) ->
828+
{error, {metadata, not_key_value}}.
829+
830+
%% @private
831+
extract_metadata_key(KeyChars) ->
832+
try list_to_binary(KeyChars) of
833+
Key -> {ok, Key}
834+
catch
835+
error:badarg -> error
584836
end.
585837

586838
%% @private

src/safe_erl_term.xrl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ Rules.
1717
{D}+ : {token, {integer, TokenLine, list_to_integer(TokenChars)}}.
1818
[\#\[\]}{,+-] : {token, {list_to_atom(TokenChars), TokenLine}}.
1919
(<<|>>|=>) : {token, {list_to_atom(TokenChars), TokenLine}}.
20-
\. : {token, {dot, TokenLine}}.
20+
% end_token (not token) lets hex_tarball stream-decode metadata.config
21+
% one form at a time via safe_erl_term:tokens/2.
22+
\. : {end_token, {dot, TokenLine}}.
2123
/ : {token, {'/', TokenLine}}.
2224
{WS}+ : skip_token.
2325

0 commit comments

Comments
 (0)