From bb03c53af5da68d0f5859518115f6537a1eb5c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 22 Oct 2026 11:12:26 +0200 Subject: [PATCH] Skip tarball root directory entries instead of crashing hex_tarball:create/3 and hex_tarball:create_docs/2 crashed with a function_clause error in lists:seq/2 when a file name resolved to the root of the tarball, such as ".", "./" or "a/..". Since https://github.com/hexpm/hex_core/pull/228 directories with files under them are dropped by computing each name's parent directories, and a name with no path components called lists:seq(1, -1). rebar3_hex passes {".", DocDir} for the docs directory itself, so its docs builds fail with hex_core v0.20.0 and later. A directory named after the root is now skipped, which gives the same tarball as v0.19.0 for a non-empty directory. A regular file or symlink with such a name returns an unsafe_path error. v0.19.0 wrote it as a member named ".", which failed to unpack to disk with eisdir. Fixes https://github.com/hexpm/hex_core/issues/251 --- src/hex_tarball.erl | 12 ++++++++-- test/hex_tarball_SUITE.erl | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/hex_tarball.erl b/src/hex_tarball.erl index 2baaf3e..fb6f5eb 100644 --- a/src/hex_tarball.erl +++ b/src/hex_tarball.erl @@ -1128,8 +1128,16 @@ validate_create_files([], _FilesRoot, Acc) -> {ok, lists:reverse(Acc)}; validate_create_files([File | Rest], FilesRoot, Acc) -> case validate_create_file(File, FilesRoot) of - {ok, ValidatedFile} -> validate_create_files(Rest, FilesRoot, [ValidatedFile | Acc]); - {error, _} = Error -> Error + {ok, {ArchiveName, Entry} = ValidatedFile} -> + %% The archive root always exists, so a directory named after it + %% isn't stored and other entries can't take its name + case {archive_path_parts(ArchiveName), Entry} of + {[], directory} -> validate_create_files(Rest, FilesRoot, Acc); + {[], _} -> {error, {tarball, {unsafe_path, ArchiveName}}}; + _ -> validate_create_files(Rest, FilesRoot, [ValidatedFile | Acc]) + end; + {error, _} = Error -> + Error end. validate_create_file({Filename, Source, Options}, FilesRoot) when is_map(Options) -> diff --git a/test/hex_tarball_SUITE.erl b/test/hex_tarball_SUITE.erl index b8c4f76..e89144d 100644 --- a/test/hex_tarball_SUITE.erl +++ b/test/hex_tarball_SUITE.erl @@ -15,6 +15,7 @@ all() -> unicode_normalization_test, latin1_filename_encoding_test, empty_directories_test, + root_entries_test, read_only_cwd_test, read_only_output_parent_test, output_under_regular_file_test, @@ -340,6 +341,50 @@ empty_directories_test(Config) -> [{Name, Type} || {Name, Type, _Size, _Mtime, _Mode, _Uid, _Gid} <- Entries] ). +root_entries_test(Config) -> + BaseDir = ?config(priv_dir, Config), + Dir = filename:absname(filename:join(BaseDir, "root_entries")), + ok = filelib:ensure_dir(filename:join([Dir, "api", "x"])), + ok = file:write_file(filename:join(Dir, "index.html"), <<"index">>), + ok = file:make_symlink("index.html", filename:join(Dir, "link")), + + Metadata = #{<<"name">> => <<"foo">>, <<"version">> => <<"1.0.0">>}, + CreateConfig = maps:put(tarball_files_root, Dir, hex_core:default_config()), + Files = [{"index.html", filename:join(Dir, "index.html")}, {"api", filename:join(Dir, "api")}], + {ok, Docs} = hex_tarball:create_docs(Files, CreateConfig), + {ok, #{tarball := Package}} = hex_tarball:create(Metadata, Files, CreateConfig), + ?assertEqual({ok, ["api", "index.html"]}, hex_erl_tar:table({binary, Docs}, [compressed])), + + lists:foreach( + fun(RootFiles) -> + {ok, Docs} = hex_tarball:create_docs(RootFiles ++ Files, CreateConfig), + {ok, #{tarball := Package}} = + hex_tarball:create(Metadata, RootFiles ++ Files, CreateConfig) + end, + [ + [{".", Dir}], + [{"./", Dir}], + [{"api/..", Dir}], + [{"", Dir}], + [{".", "."}], + [{".", Dir, #{executable => true}}], + [{".", Dir}, {"./", Dir}] + ] + ), + + {error, {tarball, {unsafe_path, "."}}} = hex_tarball:create_docs([{".", <<"index">>}]), + {error, {tarball, {unsafe_path, "api/.."}}} = + hex_tarball:create(Metadata, [{"api/..", <<"index">>}]), + {error, {tarball, {unsafe_path, "."}}} = + hex_tarball:create_docs([{".", filename:join(Dir, "index.html")}], CreateConfig), + {error, {tarball, {unsafe_path, "."}}} = + hex_tarball:create(Metadata, [{".", filename:join(Dir, "index.html")}], CreateConfig), + {error, {tarball, {unsafe_path, "."}}} = + hex_tarball:create_docs([{".", filename:join(Dir, "link")}], CreateConfig), + {error, {tarball, {unsafe_path, "."}}} = + hex_tarball:create(Metadata, [{".", filename:join(Dir, "link")}], CreateConfig), + ok. + read_only_cwd_test(Config) -> BaseDir = ?config(priv_dir, Config), Dir = filename:join(BaseDir, "read_only_cwd"),