Skip to content

Commit 13f7b78

Browse files
authored
Walk the unpacked tree without following symlinks in the mtime pass (#220)
After extracting a tarball to disk, unpack refreshed every file's mtime by listing the tree with filelib:wildcard("**"), which descends into symlinked directories, and file:write_file_info/3, which resolves symlinks. Packages created by old clients can contain in-tree symlink cycles (create rejects them today, unpack accepts them), and on such a package the pass visits every file once per cycle expansion: svx 0.2.0 holds 1,171 files but the wildcard enumerates ~47k paths, turning a 260ms extraction into multiple seconds of mtime updates. Replace the wildcard with a read_link_info walk that updates regular files and directories and skips symlinks entirely, computing the timestamp once.
1 parent 0ee703d commit 13f7b78

2 files changed

Lines changed: 55 additions & 13 deletions

File tree

src/hex_tarball.erl

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -487,10 +487,7 @@ finish_unpack(#{
487487
filelib:ensure_dir(filename:join(Output, "*")),
488488
case unpack_contents(Contents, Output, MaxUncompressedSize) of
489489
ok ->
490-
[
491-
try_updating_mtime(filename:join(Output, P))
492-
|| P <- filelib:wildcard("**", Output)
493-
],
490+
update_mtimes(Output),
494491
copy_metadata_config(Output, maps:get("metadata.config", Files)),
495492
{ok, Result};
496493
{error, Reason} ->
@@ -1099,10 +1096,7 @@ unpack_tarball(Source, Output, MaxSize) ->
10991096
filelib:ensure_dir(filename:join(Output, "*")),
11001097
case hex_erl_tar:extract(Source, [{cwd, Output}, compressed, {max_size, MaxSize}]) of
11011098
ok ->
1102-
[
1103-
try_updating_mtime(filename:join(Output, Path))
1104-
|| Path <- filelib:wildcard("**", Output)
1105-
],
1099+
update_mtimes(Output),
11061100
ok;
11071101
{error, too_big} ->
11081102
{error, {tarball, {too_big_uncompressed, MaxSize}}};
@@ -1111,11 +1105,30 @@ unpack_tarball(Source, Output, MaxSize) ->
11111105
end.
11121106

11131107
%% @private
1114-
%% let it silently fail for bad symlinks
1115-
try_updating_mtime(Path) ->
1116-
Time = calendar:universal_time(),
1117-
_ = file:write_file_info(Path, #file_info{mtime = Time}, [{time, universal}]),
1118-
ok.
1108+
%% Skips symlinks: write_file_info/3 follows them, and descending into
1109+
%% symlinked directories loops on link cycles. Failures are ignored.
1110+
update_mtimes(Dir) ->
1111+
update_mtimes(Dir, calendar:universal_time()).
1112+
1113+
update_mtimes(Dir, Time) ->
1114+
case file:list_dir(Dir) of
1115+
{ok, Names} ->
1116+
lists:foreach(fun(Name) -> update_mtime(filename:join(Dir, Name), Time) end, Names);
1117+
{error, _} ->
1118+
ok
1119+
end.
1120+
1121+
update_mtime(Path, Time) ->
1122+
case file:read_link_info(Path, [{time, universal}]) of
1123+
{ok, #file_info{type = directory}} ->
1124+
_ = file:write_file_info(Path, #file_info{mtime = Time}, [{time, universal}]),
1125+
update_mtimes(Path, Time);
1126+
{ok, #file_info{type = regular}} ->
1127+
_ = file:write_file_info(Path, #file_info{mtime = Time}, [{time, universal}]),
1128+
ok;
1129+
_ ->
1130+
ok
1131+
end.
11191132

11201133
%% @private
11211134
create_memory_tarball(Files) ->

test/hex_tarball_SUITE.erl

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ all() ->
1212
timestamps_and_permissions_test,
1313
symlinks_test,
1414
symlinks_parent_dir_test,
15+
symlink_cycle_test,
1516
unsafe_paths_to_create_test,
1617
unsupported_file_types_to_create_test,
1718
memory_test,
@@ -278,6 +279,34 @@ symlinks_parent_dir_test(Config) ->
278279

279280
ok.
280281

282+
%% dir/loop -> .. resolves inside the extraction dir but forms a cycle, so the
283+
%% post-unpack mtime pass must not traverse symlinked directories.
284+
symlink_cycle_test(Config) ->
285+
BaseDir = ?config(priv_dir, Config),
286+
Metadata = #{<<"name">> => <<"cycle">>, <<"version">> => <<"1.0.0">>},
287+
288+
Dir = filename:join(BaseDir, "cycle_dir"),
289+
ok = file:make_dir(Dir),
290+
ok = file:write_file(filename:join(Dir, "foo.sh"), <<"foo">>),
291+
ok = file:make_symlink("..", filename:join(Dir, "loop")),
292+
293+
Files = [
294+
{"dir/foo.sh", filename:join("cycle_dir", "foo.sh")},
295+
{"dir/loop", filename:join("cycle_dir", "loop")}
296+
],
297+
CreateConfig = maps:put(tarball_files_root, BaseDir, hex_core:default_config()),
298+
299+
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, Files, CreateConfig),
300+
UnpackDir = filename:join(BaseDir, "symlink_cycle"),
301+
{ok, _} = hex_tarball:unpack(Tarball, UnpackDir),
302+
303+
{ok, #file_info{type = symlink}} =
304+
file:read_link_info(filename:join([UnpackDir, "dir", "loop"])),
305+
{ok, FooShInfo} = file:read_file_info(filename:join([UnpackDir, "dir", "foo.sh"])),
306+
[{{Year, _, _}, _}] = calendar:local_time_to_universal_time_dst(FooShInfo#file_info.mtime),
307+
{{Year, _, _}, _} = calendar:local_time(),
308+
ok.
309+
281310
unsafe_paths_to_create_test(Config) ->
282311
BaseDir = ?config(priv_dir, Config),
283312
Metadata = #{<<"name">> => <<"foo">>, <<"version">> => <<"1.0.0">>},

0 commit comments

Comments
 (0)