%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually

%% @doc
%% Functions for creating and unpacking Hex tarballs.
-module(mix_hex_tarball).
-export([
    create/2, create/3,
    create_docs/1, create_docs/2,
    unpack/2, unpack/3,
    unpack_docs/2, unpack_docs/3,
    format_checksum/1,
    format_error/1
]).
-ifdef(TEST).
-export([do_decode_metadata/1, gzip/1, normalize_requirements/1]).
-endif.
-define(VERSION, <<"3">>).
-define(HASH_CHUNK_SIZE, 65536).
-define(MAX_VERSION_SIZE, 32).
-define(MAX_CHECKSUM_SIZE, 128).
-define(MAX_METADATA_SIZE, 128 * 1024).
-define(BUILD_TOOL_FILES, [
    {<<"mix.exs">>, <<"mix">>},
    {<<"rebar.config">>, <<"rebar3">>},
    {<<"rebar">>, <<"rebar3">>},
    {<<"Makefile">>, <<"make">>},
    {<<"Makefile.win">>, <<"make">>}
]).
-include_lib("kernel/include/file.hrl").

-type checksum() :: binary().
-type contents() :: [{filename(), binary()}].
-type filename() :: string().
-type files() :: [{filename(), filename() | binary()}].
-type metadata() :: map().
-type tarball() :: binary().

%%====================================================================
%% API functions
%%====================================================================

%% @doc
%% Creates a package tarball.
%%
%% Returns the binary of the tarball the "inner checksum" and "outer checksum".
%% The inner checksum is deprecated in favor of the outer checksum.
%%
%% Examples:
%%
%% ```
%% > Metadata = #{<<"name">> => <<"foo">>, <<"version">> => <<"1.0.0">>},
%% > Files = [{"src/foo.erl", <<"-module(foo).">>}],
%% > mix_hex_tarball:create(Metadata, Files).
%% {ok, #{tarball => <<86,69,...>>,
%%        outer_checksum => <<40,32,...>>,
%%        inner_checksum => <<178,12,...>>}}
%% '''
%% @end
-spec create(metadata(), files(), mix_hex_core:config()) ->
    {ok, #{
        tarball => tarball(),
        outer_checksum => checksum(),
        inner_checksum => tarball()
    }}
    | {error, term()}.
create(Metadata, Files, Config) ->
    #{
        tarball_max_size := TarballMaxSize,
        tarball_max_uncompressed_size := TarballMaxUncompressedSize
    } = Config,

    MetadataBinary = encode_metadata(Metadata),

    case valid_size(MetadataBinary, ?MAX_METADATA_SIZE) of
        false ->
            {error, {tarball, {file_too_big, "metadata.config"}}};
        true ->
            ContentsTarball = create_memory_tarball(Files),
            ContentsTarballCompressed = gzip(ContentsTarball),
            InnerChecksum = inner_checksum(?VERSION, MetadataBinary, ContentsTarballCompressed),
            InnerChecksumBase16 = encode_base16(InnerChecksum),

            OuterFiles = [
                {"VERSION", ?VERSION},
                {"CHECKSUM", InnerChecksumBase16},
                {"metadata.config", MetadataBinary},
                {"contents.tar.gz", ContentsTarballCompressed}
            ],

            case valid_size(ContentsTarball, TarballMaxUncompressedSize) of
                true ->
                    Tarball = create_memory_tarball(OuterFiles),
                    OuterChecksum = checksum(Tarball),

                    case valid_size(Tarball, TarballMaxSize) of
                        true ->
                            {ok, #{
                                tarball => Tarball,
                                outer_checksum => OuterChecksum,
                                inner_checksum => InnerChecksum
                            }};
                        false ->
                            {error, {tarball, {too_big_compressed, TarballMaxSize}}}
                    end;
                false ->
                    {error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
            end
    end.

-spec create(metadata(), files()) ->
    {ok, #{
        tarball => tarball(),
        outer_checksum => checksum(),
        inner_checksum => tarball()
    }}
    | {error, term()}.
create(Metadata, Files) ->
    create(Metadata, Files, mix_hex_core:default_config()).

%% @doc
%% Creates a docs tarball.
%%
%% Examples:
%%
%% ```
%% > Files = [{"doc/index.html", <<"Docs">>}],
%% > mix_hex_tarball:create_docs(Files).
%% {ok, <<86,69,...>>}
%% '''
%% @end
-spec create_docs(files(), mix_hex_core:config()) -> {ok, tarball()} | {error, term()}.
create_docs(Files, Config) ->
    #{
        docs_tarball_max_size := TarballMaxSize,
        docs_tarball_max_uncompressed_size := TarballMaxUncompressedSize
    } = Config,

    UncompressedTarball = create_memory_tarball(Files),

    case valid_size(UncompressedTarball, TarballMaxUncompressedSize) of
        true ->
            Tarball = gzip(UncompressedTarball),

            case valid_size(Tarball, TarballMaxSize) of
                true ->
                    {ok, Tarball};
                false ->
                    {error, {tarball, {too_big_compressed, TarballMaxSize}}}
            end;
        false ->
            {error, {tarball, {too_big_uncompressed, TarballMaxUncompressedSize}}}
    end.

-spec create_docs(files()) -> {ok, tarball()} | {error, term()}.
create_docs(Files) ->
    create_docs(Files, mix_hex_core:default_config()).

%% @doc
%% Unpacks a package tarball.
%%
%% Remember to verify the outer tarball checksum against the registry checksum
%% returned from `mix_hex_repo:get_package(Config, Package)'.
%%
%% The first argument is the tarball, either as a binary or `{file, Path}'
%% to read from a file on disk. Using `{file, Path}' avoids loading the
%% tarball into memory.
%%
%% The second argument controls the output:
%%
%% - `memory' - unpack contents into memory and return them
%% - `none' - only extract metadata and checksums, skip contents
%% - A path string - extract contents to the given directory
%%
%% Examples:
%%
%% ```
%% > mix_hex_tarball:unpack(Tarball, memory).
%% {ok,#{outer_checksum => <<...>>,
%%       contents => [{"src/foo.erl",<<"-module(foo).">>}],
%%       metadata => #{<<"name">> => <<"foo">>, ...}}}
%%
%% > mix_hex_tarball:unpack(Tarball, none).
%% {ok,#{outer_checksum => <<...>>,
%%       metadata => #{<<"name">> => <<"foo">>, ...}}}
%%
%% > mix_hex_tarball:unpack(Tarball, "path/to/unpack").
%% {ok,#{outer_checksum => <<...>>,
%%       metadata => #{<<"name">> => <<"foo">>, ...}}}
%% '''
-spec unpack
    (tarball() | {file, filename()}, memory, mix_hex_core:config()) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata(),
            contents => contents()
        }}
        | {error, term()};
    (tarball() | {file, filename()}, none, mix_hex_core:config()) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata()
        }}
        | {error, term()};
    (tarball() | {file, filename()}, filename(), mix_hex_core:config()) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata()
        }}
        | {error, term()}.
unpack(Input, memory, Config) ->
    case check_input_size(Input, Config) of
        true ->
            OuterChecksum = outer_checksum(Input),
            Source = tar_source(Input),
            case mix_hex_erl_tar:extract(Source, [memory]) of
                {ok, []} ->
                    {error, {tarball, empty}};
                {ok, FileList} ->
                    case validate_outer_file_sizes(maps:from_list(FileList)) of
                        {ok, Files} ->
                            do_unpack(Files, OuterChecksum, memory, Config);
                        {error, _} = Error ->
                            Error
                    end;
                {error, Reason} ->
                    {error, {tarball, Reason}}
            end;
        false ->
            {error, {tarball, too_big}}
    end;
unpack(Input, Output, Config) ->
    case check_input_size(Input, Config) of
        true ->
            OuterChecksum = outer_checksum(Input),
            Source = tar_source(Input),
            TmpDir = tmp_path(),
            ok = file:make_dir(TmpDir),
            try
                case mix_hex_erl_tar:extract(Source, [{cwd, TmpDir}]) of
                    ok ->
                        case read_outer_files(TmpDir) of
                            {ok, Files} ->
                                do_unpack(Files, OuterChecksum, Output, Config);
                            {error, _} = Error ->
                                Error
                        end;
                    {error, Reason} ->
                        {error, {tarball, Reason}}
                end
            after
                remove_dir(TmpDir)
            end;
        false ->
            {error, {tarball, too_big}}
    end.

%% @doc
%% Unpacks a package tarball.
%%
%% @see unpack/3
-spec unpack
    (tarball() | {file, filename()}, memory) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata(),
            contents => contents()
        }}
        | {error, term()};
    (tarball() | {file, filename()}, none) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata()
        }}
        | {error, term()};
    (tarball() | {file, filename()}, filename()) ->
        {ok, #{
            outer_checksum => checksum(),
            inner_checksum => checksum(),
            metadata => metadata()
        }}
        | {error, term()}.
unpack(Tarball, Output) ->
    unpack(Tarball, Output, mix_hex_core:default_config()).

%% @doc
%% Unpacks a documentation tarball.
%%
%% The first argument is the tarball, either as a binary or `{file, Path}'
%% to read from a file on disk. Using `{file, Path}' avoids loading the
%% tarball into memory.
%%
%% Examples:
%%
%% ```
%% > mix_hex_tarball:unpack_docs(Tarball, memory).
%% {ok, [{"index.html", <<"<!doctype>">>}, ...]}
%%
%% > mix_hex_tarball:unpack_docs(Tarball, "path/to/unpack").
%% ok
%% '''
-spec unpack_docs
    (tarball() | {file, filename()}, memory, mix_hex_core:config()) ->
        {ok, contents()} | {error, term()};
    (tarball() | {file, filename()}, filename(), mix_hex_core:config()) -> ok | {error, term()}.
unpack_docs(Input, Output, Config) ->
    case check_docs_input_size(Input, Config) of
        true ->
            MaxSize = maps:get(docs_tarball_max_uncompressed_size, Config),
            unpack_tarball(tar_source(Input), Output, MaxSize);
        false ->
            {error, {tarball, too_big}}
    end.

-spec unpack_docs
    (tarball() | {file, filename()}, memory) -> {ok, contents()} | {error, term()};
    (tarball() | {file, filename()}, filename()) -> ok | {error, term()}.
unpack_docs(Input, Output) ->
    unpack_docs(Input, Output, mix_hex_core:default_config()).

%% @doc
%% Returns base16-encoded representation of checksum.
-spec format_checksum(checksum()) -> binary().
format_checksum(Checksum) ->
    encode_base16(Checksum).

%% @doc
%% Converts an error reason term to a human-readable error message string.
-spec format_error(term()) -> string().
format_error({tarball, {file_too_big, Name}}) ->
    io_lib:format("file too big: ~s", [Name]);
format_error({tarball, empty}) ->
    "empty tarball";
format_error({tarball, {too_big_uncompressed, Size}}) ->
    io_lib:format("package exceeds max uncompressed size ~w ~s", [format_byte_size(Size), "MB"]);
format_error({tarball, {too_big_compressed, Size}}) ->
    io_lib:format("package exceeds max compressed size ~w ~s", [format_byte_size(Size), "MB"]);
format_error({tarball, {missing_files, Files}}) ->
    io_lib:format("missing files: ~p", [Files]);
format_error({tarball, {bad_version, Vsn}}) ->
    io_lib:format("unsupported version: ~p", [Vsn]);
format_error({tarball, invalid_checksum}) ->
    "invalid tarball checksum";
format_error({tarball, Reason}) ->
    "tarball error, " ++ mix_hex_erl_tar:format_error(Reason);
format_error({inner_tarball, Reason}) ->
    "inner tarball error, " ++ mix_hex_erl_tar:format_error(Reason);
format_error({metadata, invalid_terms}) ->
    "error reading package metadata: invalid terms";
format_error({metadata, not_key_value}) ->
    "error reading package metadata: not in key-value format";
format_error({metadata, Reason}) ->
    "error reading package metadata" ++ mix_safe_erl_term:format_error(Reason);
format_error({checksum_mismatch, ExpectedChecksum, ActualChecksum}) ->
    io_lib:format(
        "tarball checksum mismatch~n~n" ++
            "Expected (base16-encoded): ~s~n" ++
            "Actual   (base16-encoded): ~s",
        [encode_base16(ExpectedChecksum), encode_base16(ActualChecksum)]
    ).

format_byte_size(Size) ->
    Size / 1000000.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
inner_checksum(Version, MetadataBinary, {path, ContentsPath}) ->
    HashState0 = crypto:hash_init(sha256),
    HashState1 = crypto:hash_update(HashState0, Version),
    HashState2 = crypto:hash_update(HashState1, MetadataBinary),
    HashState3 = stream_file_hash(HashState2, ContentsPath),
    crypto:hash_final(HashState3);
inner_checksum(Version, MetadataBinary, ContentsBinary) ->
    Blob = <<Version/binary, MetadataBinary/binary, ContentsBinary/binary>>,
    crypto:hash(sha256, Blob).

%% @private
checksum(ContentsBinary) when is_binary(ContentsBinary) ->
    crypto:hash(sha256, ContentsBinary).

%% @private
tar_source({file, Path}) -> Path;
tar_source(Tarball) -> {binary, Tarball}.

%% @private
outer_checksum({file, Path}) -> file_checksum(Path);
outer_checksum(Tarball) -> crypto:hash(sha256, Tarball).

%% @private
check_input_size({file, Path}, Config) ->
    valid_file_size(Path, maps:get(tarball_max_size, Config));
check_input_size(Tarball, Config) ->
    valid_size(Tarball, maps:get(tarball_max_size, Config)).

%% @private
check_docs_input_size({file, Path}, Config) ->
    valid_file_size(Path, maps:get(docs_tarball_max_size, Config));
check_docs_input_size(Tarball, Config) ->
    valid_size(Tarball, maps:get(docs_tarball_max_size, Config)).

%% @private
encode_metadata(Meta) ->
    Data = lists:map(
        fun(MetaPair) ->
            String = io_lib_pretty:print(binarify(MetaPair), [{encoding, utf8}]),
            unicode:characters_to_binary([String, ".\n"])
        end,
        maps:to_list(Meta)
    ),
    iolist_to_binary(Data).

%% @private
do_unpack(Files, OuterChecksum, Output, Config) ->
    State = #{
        inner_checksum => undefined,
        outer_checksum => OuterChecksum,
        contents => undefined,
        files => Files,
        metadata => undefined,
        output => Output,
        config => Config
    },
    State1 = check_files(State),
    State2 = check_version(State1),
    State3 = check_inner_checksum(State2),
    State4 = decode_metadata(State3),
    finish_unpack(State4).

%% @private
finish_unpack({error, _} = Error) ->
    Error;
finish_unpack(#{
    metadata := Metadata,
    files := Files,
    inner_checksum := InnerChecksum,
    outer_checksum := OuterChecksum,
    output := Output,
    config := Config
}) ->
    _ = maps:get("VERSION", Files),
    Contents = maps:get("contents.tar.gz", Files),
    MaxUncompressedSize = maps:get(tarball_max_uncompressed_size, Config),

    Result = #{
        inner_checksum => InnerChecksum,
        outer_checksum => OuterChecksum,
        metadata => Metadata
    },

    case Output of
        none ->
            {ok, Result};
        memory ->
            case unpack_contents(Contents, memory, MaxUncompressedSize) of
                {ok, UnpackedContents} ->
                    {ok, Result#{contents => UnpackedContents}};
                {error, Reason} ->
                    {error, {inner_tarball, Reason}}
            end;
        _ ->
            filelib:ensure_dir(filename:join(Output, "*")),
            case unpack_contents(Contents, Output, MaxUncompressedSize) of
                ok ->
                    [
                        try_updating_mtime(filename:join(Output, P))
                     || P <- filelib:wildcard("**", Output)
                    ],
                    copy_metadata_config(Output, maps:get("metadata.config", Files)),
                    {ok, Result};
                {error, Reason} ->
                    {error, {inner_tarball, Reason}}
            end
    end.

%% @private
unpack_contents(Contents, Output, MaxSize) ->
    Opts =
        case Output of
            memory -> [memory, compressed];
            _ -> [{cwd, Output}, compressed]
        end,
    Source =
        case Contents of
            {path, ContentsPath} -> ContentsPath;
            ContentsBinary -> {binary, ContentsBinary}
        end,
    case mix_hex_erl_tar:extract(Source, [{max_size, MaxSize} | Opts]) of
        {error, too_big} ->
            {error, {too_big_uncompressed, MaxSize}};
        Other ->
            Other
    end.

%% @private
copy_metadata_config(Output, MetadataBinary) ->
    ok = file:write_file(filename:join(Output, "hex_metadata.config"), MetadataBinary).

%% @private
check_files(#{files := Files} = State) ->
    RequiredFiles = ["VERSION", "CHECKSUM", "metadata.config", "contents.tar.gz"],
    case diff_keys(Files, RequiredFiles, []) of
        ok ->
            State;
        {error, {missing_keys, Keys}} ->
            {error, {tarball, {missing_files, Keys}}}
    end.

%% @private
check_version({error, _} = Error) ->
    Error;
check_version(#{files := Files} = State) ->
    case maps:get("VERSION", Files) of
        <<"3">> ->
            State;
        Version ->
            {error, {tarball, {bad_version, Version}}}
    end.

%% @private
% Note: This checksum is deprecated
check_inner_checksum({error, _} = Error) ->
    Error;
check_inner_checksum(#{files := Files} = State) ->
    ChecksumBase16 = maps:get("CHECKSUM", Files),
    ExpectedChecksum = decode_base16(ChecksumBase16),

    Version = maps:get("VERSION", Files),
    MetadataBinary = maps:get("metadata.config", Files),
    Contents = maps:get("contents.tar.gz", Files),
    ActualChecksum = inner_checksum(Version, MetadataBinary, Contents),

    if
        byte_size(ExpectedChecksum) /= 32 ->
            {error, {tarball, invalid_inner_checksum}};
        ExpectedChecksum == ActualChecksum ->
            maps:put(inner_checksum, ExpectedChecksum, State);
        true ->
            {error, {tarball, {inner_checksum_mismatch, ExpectedChecksum, ActualChecksum}}}
    end.

%% @private
decode_metadata({error, _} = Error) ->
    Error;
decode_metadata(#{files := #{"metadata.config" := Binary}} = State) when is_binary(Binary) ->
    case do_decode_metadata(Binary) of
        #{} = Metadata -> maps:put(metadata, normalize_metadata(Metadata), State);
        Other -> Other
    end.

%% @private
do_decode_metadata(Binary) when is_binary(Binary) ->
    {ok, String} = characters_to_list(Binary),

    case mix_safe_erl_term:string(String) of
        {ok, Tokens, _Line} ->
            try
                Terms = mix_safe_erl_term:terms(Tokens),
                maps:from_list(Terms)
            catch
                error:function_clause ->
                    {error, {metadata, invalid_terms}};
                error:badarg ->
                    {error, {metadata, not_key_value}}
            end;
        {error, {_Line, mix_safe_erl_term, Reason}, _Line2} ->
            {error, {metadata, Reason}}
    end.

%% @private
characters_to_list(Binary) ->
    case unicode:characters_to_list(Binary) of
        List when is_list(List) ->
            {ok, List};
        {error, _, _} ->
            case unicode:characters_to_list(Binary, latin1) of
                List when is_list(List) -> {ok, List};
                Other -> Other
            end
    end.

%% @private
normalize_metadata(Metadata1) ->
    Metadata2 = maybe_update_with(<<"requirements">>, fun normalize_requirements/1, Metadata1),
    Metadata3 = maybe_update_with(<<"links">>, fun try_into_map/1, Metadata2),
    Metadata4 = maybe_update_with(<<"extra">>, fun try_into_nested_map/1, Metadata3),
    guess_build_tools(Metadata4).

%% @private
normalize_requirements(Requirements) ->
    case is_list(Requirements) andalso (Requirements /= []) andalso is_list(hd(Requirements)) of
        true ->
            maps:from_list(lists:map(fun normalize_legacy_requirement/1, Requirements));
        false ->
            try_into_map(fun normalize_normal_requirement/1, Requirements)
    end.

%% @private
normalize_normal_requirement({Name, Requirement}) ->
    {Name, try_into_map(Requirement)}.

%% @private
normalize_legacy_requirement(Requirement) ->
    Map = maps:from_list(Requirement),
    Name = maps:get(<<"name">>, Map),
    {Name, maps:without([<<"name">>], Map)}.

%% @private
guess_build_tools(#{<<"build_tools">> := BuildTools} = Metadata) when is_list(BuildTools) ->
    Metadata;
guess_build_tools(#{<<"files">> := Filenames} = Metadata) ->
    BaseFiles = [
        Filename
     || Filename <- Filenames, filename:dirname(binary_to_list(Filename)) == "."
    ],
    BuildTools = lists:usort([
        Tool
     || {Filename, Tool} <- ?BUILD_TOOL_FILES, lists:member(Filename, BaseFiles)
    ]),
    Metadata#{<<"build_tools">> => BuildTools};
guess_build_tools(Metadata) ->
    Metadata.

%%====================================================================
%% Tar Helpers
%%====================================================================

%% @private
unpack_tarball(Source, memory, MaxSize) ->
    case mix_hex_erl_tar:extract(Source, [memory, compressed, {max_size, MaxSize}]) of
        {error, too_big} ->
            {error, {tarball, {too_big_uncompressed, MaxSize}}};
        Other ->
            Other
    end;
unpack_tarball(Source, Output, MaxSize) ->
    filelib:ensure_dir(filename:join(Output, "*")),
    case mix_hex_erl_tar:extract(Source, [{cwd, Output}, compressed, {max_size, MaxSize}]) of
        ok ->
            [
                try_updating_mtime(filename:join(Output, Path))
             || Path <- filelib:wildcard("**", Output)
            ],
            ok;
        {error, too_big} ->
            {error, {tarball, {too_big_uncompressed, MaxSize}}};
        Other ->
            Other
    end.

%% @private
%% let it silently fail for bad symlinks
try_updating_mtime(Path) ->
    Time = calendar:universal_time(),
    _ = file:write_file_info(Path, #file_info{mtime = Time}, [{time, universal}]),
    ok.

%% @private
create_memory_tarball(Files) ->
    Path = tmp_path(),
    {ok, Tar} = mix_hex_erl_tar:open(Path, [write]),

    try
        add_files(Tar, Files)
    after
        ok = mix_hex_erl_tar:close(Tar)
    end,
    {ok, Tarball} = file:read_file(Path),
    ok = file:delete(Path),
    Tarball.

%% @private
tmp_path() ->
    "tmp_" ++ binary_to_list(encode_base16(crypto:strong_rand_bytes(32))).

%% @private
add_files(Tar, Files) when is_list(Files) ->
    lists:map(fun(File) -> add_file(Tar, File) end, Files).

%% @private
add_file(Tar, {Filename, Contents}) when is_list(Filename) and is_binary(Contents) ->
    ok = mix_hex_erl_tar:add(Tar, Contents, Filename, tar_opts());
add_file(Tar, Filename) when is_list(Filename) ->
    add_file(Tar, {Filename, Filename});
add_file(Tar, {Filename, AbsFilename}) when is_list(Filename), is_list(AbsFilename) ->
    {ok, FileInfo} = file:read_link_info(AbsFilename, []),

    case FileInfo#file_info.type of
        symlink ->
            ok = mix_hex_erl_tar:add(Tar, {Filename, AbsFilename}, tar_opts());
        directory ->
            case file:list_dir(AbsFilename) of
                {ok, []} ->
                    mix_hex_erl_tar:add(Tar, {Filename, AbsFilename}, tar_opts());
                {ok, _} ->
                    ok
            end;
        _ ->
            Mode = FileInfo#file_info.mode,
            {ok, Contents} = file:read_file(AbsFilename),
            ok = mix_hex_erl_tar:add(Tar, Contents, Filename, [{mode, Mode} | tar_opts()])
    end.

%% @private
tar_opts() ->
    NixEpoch = calendar:datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}}),
    Y2kEpoch = calendar:datetime_to_gregorian_seconds({{2000, 1, 1}, {0, 0, 0}}),
    Epoch = Y2kEpoch - NixEpoch,
    [{atime, Epoch}, {mtime, Epoch}, {ctime, Epoch}, {uid, 0}, {gid, 0}].

%% @private
%% Reproducible gzip by not setting mtime and OS
%%
%% From https://tools.ietf.org/html/rfc1952
%%
%% +---+---+---+---+---+---+---+---+---+---+
%% |ID1|ID2|CM |FLG|     MTIME     |XFL|OS | (more-->)
%% +---+---+---+---+---+---+---+---+---+---+
%%
%% +=======================+
%% |...compressed blocks...| (more-->)
%% +=======================+
%%
%% +---+---+---+---+---+---+---+---+
%% |     CRC32     |     ISIZE     |
%% +---+---+---+---+---+---+---+---+
gzip(Uncompressed) ->
    Compressed = gzip_no_header(Uncompressed),
    Header = <<31, 139, 8, 0, 0, 0, 0, 0, 0, 0>>,
    Crc = erlang:crc32(Uncompressed),
    Size = byte_size(Uncompressed),
    Trailer = <<Crc:32/little, Size:32/little>>,
    iolist_to_binary([Header, Compressed, Trailer]).

%% @private
gzip_no_header(Uncompressed) ->
    Zstream = zlib:open(),

    try
        zlib:deflateInit(Zstream, default, deflated, -15, 8, default),
        Compressed = zlib:deflate(Zstream, Uncompressed, finish),
        zlib:deflateEnd(Zstream),
        iolist_to_binary(Compressed)
    after
        zlib:close(Zstream)
    end.

%%====================================================================
%% Helpers
%%====================================================================

%% @private
valid_size(Binary, infinity) when is_binary(Binary) ->
    true;
valid_size(Binary, Limit) when is_binary(Binary) and is_integer(Limit) ->
    byte_size(Binary) =< Limit.

%% @private
valid_file_size(_Path, infinity) ->
    true;
valid_file_size(Path, Limit) when is_integer(Limit) ->
    case file:read_file_info(Path) of
        {ok, #file_info{size = Size}} -> Size =< Limit;
        {error, _} -> false
    end.

%% @private
file_checksum(Path) ->
    {ok, Fd} = file:open(Path, [read, raw, binary]),
    try
        file_checksum_loop(Fd, crypto:hash_init(sha256))
    after
        file:close(Fd)
    end.

%% @private
file_checksum_loop(Fd, HashState) ->
    case file:read(Fd, ?HASH_CHUNK_SIZE) of
        {ok, Data} -> file_checksum_loop(Fd, crypto:hash_update(HashState, Data));
        eof -> crypto:hash_final(HashState)
    end.

%% @private
stream_file_hash(HashState, Path) ->
    {ok, Fd} = file:open(Path, [read, raw, binary]),
    try
        stream_file_hash_loop(Fd, HashState)
    after
        file:close(Fd)
    end.

%% @private
stream_file_hash_loop(Fd, HashState) ->
    case file:read(Fd, ?HASH_CHUNK_SIZE) of
        {ok, Data} -> stream_file_hash_loop(Fd, crypto:hash_update(HashState, Data));
        eof -> HashState
    end.

%% @private
%% Reads outer tar files from a directory after extraction.
%% Small files (VERSION, CHECKSUM, metadata.config) are read into memory.
%% contents.tar.gz is referenced by path.
read_outer_files(Dir) ->
    RequiredFiles = ["VERSION", "CHECKSUM", "metadata.config", "contents.tar.gz"],
    case read_outer_files(Dir, RequiredFiles, #{}) of
        {ok, Files} ->
            validate_outer_file_sizes(Files);
        {error, _} = Error ->
            Error
    end.

read_outer_files(_Dir, [], Acc) ->
    {ok, Acc};
read_outer_files(Dir, ["contents.tar.gz" | Rest], Acc) ->
    Path = filename:join(Dir, "contents.tar.gz"),
    case filelib:is_regular(Path) of
        true ->
            read_outer_files(Dir, Rest, Acc#{"contents.tar.gz" => {path, Path}});
        false ->
            {error, {tarball, {missing_files, ["contents.tar.gz"]}}}
    end;
read_outer_files(Dir, [Name | Rest], Acc) ->
    Path = filename:join(Dir, Name),
    case file:read_file(Path) of
        {ok, Data} ->
            read_outer_files(Dir, Rest, Acc#{Name => Data});
        {error, _} ->
            {error, {tarball, {missing_files, [Name]}}}
    end.

%% @private
validate_outer_file_sizes(Files) ->
    case byte_size(maps:get("VERSION", Files, <<>>)) > ?MAX_VERSION_SIZE of
        true ->
            {error, {tarball, {file_too_big, "VERSION"}}};
        false ->
            case byte_size(maps:get("CHECKSUM", Files, <<>>)) > ?MAX_CHECKSUM_SIZE of
                true ->
                    {error, {tarball, {file_too_big, "CHECKSUM"}}};
                false ->
                    case byte_size(maps:get("metadata.config", Files, <<>>)) > ?MAX_METADATA_SIZE of
                        true -> {error, {tarball, {file_too_big, "metadata.config"}}};
                        false -> {ok, Files}
                    end
            end
    end.

%% @private
remove_dir(Dir) ->
    case file:list_dir(Dir) of
        {ok, Entries} ->
            lists:foreach(
                fun(Entry) ->
                    Path = filename:join(Dir, Entry),
                    case filelib:is_dir(Path) of
                        true -> remove_dir(Path);
                        false -> file:delete(Path)
                    end
                end,
                Entries
            ),
            file:del_dir(Dir);
        {error, _} ->
            ok
    end.

%% @private
binarify(Binary) when is_binary(Binary) -> Binary;
binarify(Number) when is_number(Number) -> Number;
binarify(Atom) when Atom == undefined orelse is_boolean(Atom) -> Atom;
binarify(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
binarify(List) when is_list(List) ->
    [binarify(E) || E <- List];
binarify({Key, Value}) ->
    {binarify(Key), binarify(Value)};
binarify(Map) when is_map(Map) ->
    List = maps:to_list(Map),
    lists:map(fun({K, V}) -> binarify({K, V}) end, List).

%% @private
diff_keys(Map, RequiredKeys, OptionalKeys) ->
    Keys = maps:keys(Map),
    MissingKeys = RequiredKeys -- Keys,
    UnknownKeys = Keys -- (RequiredKeys ++ OptionalKeys),

    case {MissingKeys, UnknownKeys} of
        {[], []} ->
            ok;
        % Server should validate this but clients should not
        % {_, [_ | _]} ->
        %     {error, {unknown_keys, UnknownKeys}};

        _ ->
            {error, {missing_keys, MissingKeys}}
    end.

%% @private
maybe_update_with(Key, Fun, Map) ->
    case maps:find(Key, Map) of
        {ok, Value} -> maps:put(Key, Fun(Value), Map);
        error -> Map
    end.

%% @private
try_into_map(List) ->
    try_into_map(fun(X) -> X end, List).

%% @private
try_into_map(Fun, Input) ->
    case has_map_shape(Input) of
        true -> maps:from_list(lists:map(Fun, Input));
        false -> Input
    end.

%% @private
try_into_nested_map(List) ->
    try_into_nested_map(fun(X) -> X end, List).

%% @private
try_into_nested_map(Fun, Input) ->
    case has_map_shape(Input) of
        true ->
            maps:from_list(
                lists:map(
                    fun({Key, Value}) ->
                        Fun({Key, try_into_nested_map(Fun, Value)})
                    end,
                    Input
                )
            );
        false ->
            Input
    end.

%% @private
has_map_shape(Input) ->
    is_list(Input) andalso
        lists:all(fun(E) -> is_tuple(E) andalso (tuple_size(E) == 2) end, Input).

%% @private
encode_base16(Binary) ->
    <<X:256/big-unsigned-integer>> = Binary,
    String = string:to_upper(lists:flatten(io_lib:format("~64.16.0b", [X]))),
    list_to_binary(String).

%% Based on https://github.com/goj/base16/blob/master/src/base16.erl
%% (C) 2012, Erlang Solutions Ltd.

%% @private
decode_base16(Base16) ->
    <<<<(unhex(H) bsl 4 + unhex(L))>> || <<H, L>> <= Base16>>.

%% @private
unhex(D) when $0 =< D andalso D =< $9 ->
    D - $0;
unhex(D) when $a =< D andalso D =< $f ->
    10 + D - $a;
unhex(D) when $A =< D andalso D =< $F ->
    10 + D - $A.
