Current section
Files
Jump to
Current section
Files
src/hex_repo.erl
%% @doc
%% Repo API.
-module(hex_repo).
-export([
get_names/1,
get_versions/1,
get_package/2,
get_policy/2,
get_tarball/3,
get_tarball_to_file/4,
get_docs/3,
get_docs_to_file/4,
get_public_key/1,
get_hex_installs/1,
fingerprint/1,
fingerprint_equal/2
]).
%%====================================================================
%% API functions
%%====================================================================
%% @doc
%% Gets names resource from the repository.
%%
%% Examples:
%%
%% ```
%% > hex_repo:get_names(hex_core:default_config()).
%% {ok,{200, ...,
%% #{packages => [
%% #{name => <<"package1">>},
%% #{name => <<"package2">>},
%% ...]}}}
%% '''
%% @end
get_names(Config) when is_map(Config) ->
Decoder = fun(Data) ->
hex_registry:decode_names(Data, verify_repo(Config))
end,
get_protobuf(Config, <<"names">>, Decoder).
%% @doc
%% Gets versions resource from the repository.
%%
%% Examples:
%%
%% ```
%% > hex_repo:get_versions(Config).
%% {ok, {200, ...,
%% #{packages => [
%% #{name => <<"package1">>, retired => [],
%% versions => [<<"1.0.0">>]},
%% #{name => <<"package2">>, retired => [<<"0.5.0>>"],
%% versions => [<<"0.5.0">>, <<"1.0.0">>]},
%% ...]}}}
%% '''
%% @end
get_versions(Config) when is_map(Config) ->
Decoder = fun(Data) ->
hex_registry:decode_versions(Data, verify_repo(Config))
end,
get_protobuf(Config, <<"versions">>, Decoder).
%% @doc
%% Gets package resource from the repository.
%%
%% Examples:
%%
%% ```
%% > hex_repo:get_package(hex_core:default_config(), <<"package1">>).
%% {ok, {200, ...,
%% #{name => <<"package1">>,
%% releases => [
%% #{checksum => ..., version => <<"0.5.0">>, dependencies => []},
%% #{checksum => ..., version => <<"1.0.0">>, dependencies => [
%% #{package => <<"package2">>, optional => true, requirement => <<"~> 0.1">>}
%% ]},
%% ]}}}
%% '''
%% @end
get_package(Config, Name) when is_binary(Name) and is_map(Config) ->
Verify = maps:get(repo_verify_origin, Config, true),
Decoder = fun(Data) ->
case Verify of
true -> hex_registry:decode_package(Data, repo_name(Config), Name);
false -> hex_registry:decode_package(Data, no_verify, no_verify)
end
end,
get_protobuf(Config, <<"packages/", Name/binary>>, Decoder).
%% @doc
%% Gets policy resource from the repository.
%%
%% Requires `repo_organization' to be set in the config; policies are
%% always served from the per-organization namespace
%% (`/repos/<organization>/policies/<name>'). Returns
%% `{error, missing_repo_organization}' when it is not set.
%%
%% Examples:
%%
%% ```
%% > Config = (hex_core:default_config())#{repo_organization => <<"myorg">>},
%% > hex_repo:get_policy(Config, <<"strict-prod">>).
%% {ok, {200, ...,
%% #{repository => <<"myorg">>,
%% name => <<"strict-prod">>,
%% visibility => 'VISIBILITY_PUBLIC'}}}
%% '''
%% @end
get_policy(Config, Name) when is_binary(Name) and is_map(Config) ->
case maps:get(repo_organization, Config, undefined) of
undefined ->
{error, missing_repo_organization};
Org when is_binary(Org) ->
Verify = maps:get(repo_verify_origin, Config, true),
Decoder = fun(Data) ->
case Verify of
true -> hex_registry:decode_policy(Data, Org, Name);
false -> hex_registry:decode_policy(Data, no_verify, no_verify)
end
end,
get_protobuf(Config, <<"policies/", Name/binary>>, Decoder)
end.
%% @doc
%% Gets tarball from the repository.
%%
%% Examples:
%%
%% ```
%% > {ok, {200, _, Tarball}} = hex_repo:get_tarball(hex_core:default_config(), <<"package1">>, <<"1.0.0">>),
%% > {ok, #{metadata := Metadata}} = hex_tarball:unpack(Tarball, "/tmp/package").
%% '''
%% @end
get_tarball(Config, Name, Version) ->
ReqHeaders = make_headers(Config),
case get(Config, tarball_url(Config, Name, Version), ReqHeaders) of
{ok, {200, RespHeaders, Tarball}} ->
{ok, {200, RespHeaders, Tarball}};
Other ->
Other
end.
%% @doc
%% Gets tarball from the repository and writes it to a file.
%%
%% Examples:
%%
%% ```
%% > {ok, {200, _}} = hex_repo:get_tarball_to_file(hex_core:default_config(), <<"package1">>, <<"1.0.0">>, "/tmp/package.tar"),
%% > {ok, #{metadata := Metadata}} = hex_tarball:unpack({file, "/tmp/package.tar"}, "/tmp/package").
%% '''
%% @end
get_tarball_to_file(Config, Name, Version, Filename) ->
ReqHeaders = make_headers(Config),
case get_to_file(Config, tarball_url(Config, Name, Version), ReqHeaders, Filename) of
{ok, {200, RespHeaders}} ->
{ok, {200, RespHeaders}};
Other ->
Other
end.
%% @doc
%% Gets docs tarball from the repository.
%%
%% Examples:
%%
%% ```
%% > {ok, {200, _, Docs}} = hex_repo:get_docs(hex_core:default_config(), <<"package1">>, <<"1.0.0">>),
%% > hex_tarball:unpack_docs(Docs, "/tmp/docs")
%% ok
%% '''
get_docs(Config, Name, Version) ->
ReqHeaders = make_headers(Config),
case get(Config, docs_url(Config, Name, Version), ReqHeaders) of
{ok, {200, RespHeaders, Docs}} ->
{ok, {200, RespHeaders, Docs}};
Other ->
Other
end.
%% @doc
%% Gets docs tarball from the repository and writes it to a file.
%%
%% Examples:
%%
%% ```
%% > {ok, {200, _}} = hex_repo:get_docs_to_file(hex_core:default_config(), <<"package1">>, <<"1.0.0">>, "/tmp/docs.tar.gz"),
%% > ok = hex_tarball:unpack_docs({file, "/tmp/docs.tar.gz"}, "/tmp/docs").
%% '''
get_docs_to_file(Config, Name, Version, Filename) ->
ReqHeaders = make_headers(Config),
case get_to_file(Config, docs_url(Config, Name, Version), ReqHeaders, Filename) of
{ok, {200, RespHeaders}} ->
{ok, {200, RespHeaders}};
Other ->
Other
end.
%% @doc
%% Gets the public key from the repository.
%%
%% Examples:
%%
%% ```
%% > hex_repo:get_public_key(hex_core:default_config())
%% {ok, {200, _, PublicKey}}
%% '''
get_public_key(Config) ->
ReqHeaders = make_headers(Config),
URI = build_url(Config, <<"public_key">>),
case get(Config, URI, ReqHeaders) of
{ok, {200, RespHeaders, PublicKey}} ->
{ok, {200, RespHeaders, PublicKey}};
Other ->
Other
end.
%% @doc
%% Gets Hex installation versions CSV from repository.
%%
%% Examples:
%%
%% ```
%% > hex_repo:get_hex_installs(hex_core:default_config()).
%% {ok, {200, ..., <<"1.0.0,abc123,1.13.0\n1.1.0,def456,1.14.0\n...">>}}
%% '''
%% @end
get_hex_installs(Config) ->
ReqHeaders = make_headers(Config),
URI = build_url(Config, <<"installs/hex-1.x.csv">>),
case get(Config, URI, ReqHeaders) of
{ok, {200, RespHeaders, CSV}} ->
{ok, {200, RespHeaders, CSV}};
Other ->
Other
end.
%% @doc
%% Computes a SHA256 fingerprint of a PEM-encoded public key.
%%
%% Returns a string in the format "SHA256:" followed by base64, which can be used
%% to verify public keys out-of-band.
%%
%% Examples:
%%
%% ```
%% > hex_repo:fingerprint(PublicKeyPem).
%% "SHA256:abc123..."
%% '''
%% @end
-spec fingerprint(binary()) -> string().
fingerprint(PublicKeyPem) when is_binary(PublicKeyPem) ->
[PemEntry] = public_key:pem_decode(PublicKeyPem),
PublicKey = public_key:pem_entry_decode(PemEntry),
application:ensure_all_started(ssh),
ssh:hostkey_fingerprint(sha256, PublicKey).
%% @doc
%% Compares a PEM-encoded public key against an expected fingerprint.
%%
%% Uses constant-time comparison to prevent timing attacks.
%%
%% Examples:
%%
%% ```
%% > hex_repo:fingerprint_equal(PublicKeyPem, "SHA256:abc123...").
%% true
%% '''
%% @end
-spec fingerprint_equal(binary(), iodata()) -> boolean().
fingerprint_equal(PublicKeyPem, ExpectedFingerprint) when is_binary(PublicKeyPem) ->
ActualFingerprint = fingerprint(PublicKeyPem),
constant_time_compare(
list_to_binary(ActualFingerprint),
iolist_to_binary(ExpectedFingerprint)
).
%% @private
%% Constant-time comparison to prevent timing attacks.
%% Uses crypto:hash_equals/2 on OTP 25+, falls back to manual comparison on older versions.
-if(?OTP_RELEASE >= 25).
constant_time_compare(A, B) when byte_size(A) =/= byte_size(B) ->
false;
constant_time_compare(A, B) ->
crypto:hash_equals(A, B).
-else.
constant_time_compare(A, B) when byte_size(A) =:= byte_size(B) ->
constant_time_compare(A, B, 0);
constant_time_compare(_, _) ->
false.
constant_time_compare(<<X, RestA/binary>>, <<Y, RestB/binary>>, Acc) ->
constant_time_compare(RestA, RestB, Acc bor (X bxor Y));
constant_time_compare(<<>>, <<>>, Acc) ->
Acc =:= 0.
-endif.
%%====================================================================
%% Internal functions
%%====================================================================
%% @private
get(Config, URI, Headers) ->
hex_http:request(Config, get, URI, Headers, undefined).
%% @private
get_to_file(Config, URI, Headers, Filename) ->
hex_http:request_to_file(Config, get, URI, Headers, undefined, Filename).
%% @private
get_protobuf(Config, Path, Decoder) ->
PublicKey = maps:get(repo_public_key, Config),
ReqHeaders = make_headers(Config),
case get(Config, build_url(Config, Path), ReqHeaders) of
{ok, {200, RespHeaders, Compressed}} ->
Signed = zlib:gunzip(Compressed),
case decode(Signed, PublicKey, Decoder, Config) of
{ok, Decoded} ->
{ok, {200, RespHeaders, Decoded}};
{error, _} = Error ->
Error
end;
Other ->
Other
end.
%% @private
decode(Signed, PublicKey, Decoder, Config) ->
Verify = maps:get(repo_verify, Config, true),
case Verify of
true ->
case hex_registry:decode_and_verify_signed(Signed, PublicKey) of
{ok, Payload} ->
Decoder(Payload);
Other ->
Other
end;
false ->
#{payload := Payload} = hex_registry:decode_signed(Signed),
Decoder(Payload)
end.
%% @private
verify_repo(Config) ->
case maps:get(repo_verify_origin, Config, true) of
true -> repo_name(Config);
false -> no_verify
end.
%% @private
repo_name(#{repo_organization := Name}) when is_binary(Name) -> Name;
repo_name(#{repo_name := Name}) when is_binary(Name) -> Name.
%% @private
tarball_url(Config, Name, Version) ->
Filename = tarball_filename(Name, Version),
build_url(Config, <<"tarballs/", Filename/binary>>).
%% @private
docs_url(Config, Name, Version) ->
Filename = docs_filename(Name, Version),
build_url(Config, <<"docs/", Filename/binary>>).
%% @private
build_url(#{repo_url := URI, repo_organization := Org}, Path) when is_binary(Org) ->
<<URI/binary, "/repos/", Org/binary, "/", Path/binary>>;
build_url(#{repo_url := URI, repo_organization := undefined}, Path) ->
<<URI/binary, "/", Path/binary>>;
build_url(Config, Path) ->
build_url(Config#{repo_organization => undefined}, Path).
%% @private
tarball_filename(Name, Version) ->
<<Name/binary, "-", Version/binary, ".tar">>.
%% @private
docs_filename(Name, Version) ->
<<Name/binary, "-", Version/binary, ".tar.gz">>.
%% @private
make_headers(Config) ->
maps:fold(fun set_header/3, #{}, Config).
%% @private
set_header(http_etag, ETag, Headers) when is_binary(ETag) ->
maps:put(<<"if-none-match">>, ETag, Headers);
set_header(repo_key, Token, Headers) when is_binary(Token) ->
maps:put(<<"authorization">>, Token, Headers);
set_header(api_otp, OTP, Headers) when is_binary(OTP) ->
maps:put(<<"x-hex-otp">>, OTP, Headers);
set_header(_, _, Headers) ->
Headers.