rebar3/src/rebar_packages.erl

465 lines
18 KiB
Erlang
Raw Permalink Normal View History

2021-12-27 19:09:21 +00:00
-module(rebar_packages).
-export([get/2
,get_all_names/1
,registry_dir/1
,package_dir/2
,find_highest_matching/5
,verify_table/1
,format_error/1
,update_package/3
,resolve_version/6]).
-ifdef(TEST).
-export([new_package_table/0, find_highest_matching_/5, cmp_/4, cmpl_/4, valid_vsn/1]).
-endif.
-export_type([package/0]).
-include("rebar.hrl").
-include_lib("providers/include/providers.hrl").
-type pkg_name() :: binary() | atom().
-type vsn() :: binary().
-type package() :: pkg_name() | {pkg_name(), vsn()}.
format_error({missing_package, Name, Vsn}) ->
io_lib:format("Package not found in any repo: ~ts ~ts", [rebar_utils:to_binary(Name),
rebar_utils:to_binary(Vsn)]);
format_error({missing_package, Pkg}) ->
io_lib:format("Package not found in any repo: ~p", [Pkg]).
-spec get(rebar_hex_repos:repo(), binary()) -> {ok, map()} | {error, term()}.
get(Config, Name) ->
try r3_hex_api_package:get(Config, Name) of
{ok, {200, _Headers, PkgInfo}} ->
{ok, PkgInfo};
{ok, {404, _, _}} ->
{error, not_found};
Error ->
?DEBUG("Hex api request failed: ~p", [Error]),
{error, unknown}
catch
error:{badmatch, {error, {failed_connect, _}}} ->
{error, failed_to_connect};
_:Exception ->
?DEBUG("hex_api_package:get failed: ~p", [Exception]),
{error, unknown}
end.
-spec get_all_names(rebar_state:t()) -> [binary()].
get_all_names(State) ->
verify_table(State),
lists:usort(ets:select(?PACKAGE_TABLE, [{#package{key={'$1', '_', '_'},
_='_'},
[], ['$1']}])).
-spec get_package_versions(unicode:unicode_binary(), ec_semver:semver(),
unicode:unicode_binary(),
ets:tid(), rebar_state:t()) -> [vsn()].
get_package_versions(Dep, {_, AlphaInfo}, Repo, Table, State) ->
?MODULE:verify_table(State),
AllowPreRelease = rebar_state:get(State, deps_allow_prerelease, false)
orelse AlphaInfo =/= {[],[]},
ets:select(Table, [{#package{key={Dep, {'$1', '$2'}, Repo},
_='_'},
[{'==', '$2', {{[],[]}}} || not AllowPreRelease], [{{'$1', '$2'}}]}]).
-spec get_package(unicode:unicode_binary(), unicode:unicode_binary(),
binary() | undefined | '_',
[unicode:unicode_binary()] | ['_'], ets:tab(), rebar_state:t())
-> {ok, #package{}} | not_found.
get_package(Dep, Vsn, undefined, Repos, Table, State) ->
get_package(Dep, Vsn, '_', Repos, Table, State);
get_package(Dep, Vsn, Hash, Repos, Table, State) ->
?MODULE:verify_table(State),
MatchingPackages = ets:select(Table, [{#package{key={Dep, ec_semver:parse(Vsn), Repo},
_='_'}, [], ['$_']} || Repo <- Repos]),
PackagesWithProperHash = lists:filter(
fun(#package{key = {_Dep, _Vsn, Repo}, outer_checksum = PkgChecksum}) ->
if (PkgChecksum =/= Hash) andalso (Hash =/= '_') ->
?WARN("Checksum mismatch for package ~ts-~ts from repo ~ts", [Dep, Vsn, Repo]),
false;
true ->
true
end
end, MatchingPackages
),
case PackagesWithProperHash of
%% have to allow multiple matches in the list for cases that Repo is `_`
[Package | _] ->
{ok, Package};
[] ->
not_found
end.
new_package_table() ->
?PACKAGE_TABLE = ets:new(?PACKAGE_TABLE, [named_table, public, ordered_set, {keypos, 2}]),
ets:insert(?PACKAGE_TABLE, {?PACKAGE_INDEX_VERSION, package_index_version}).
load_and_verify_version(State) ->
{ok, RegistryDir} = registry_dir(State),
case ets:file2tab(filename:join(RegistryDir, ?INDEX_FILE)) of
{ok, _} ->
case ets:lookup_element(?PACKAGE_TABLE, package_index_version, 1) of
?PACKAGE_INDEX_VERSION ->
true;
V ->
%% no reason to confuse the user since we just start fresh and they
%% shouldn't notice, so log as a debug message only
?DEBUG("Package index version mismatch. Current version ~p, this rebar3 expecting ~p",
[V, ?PACKAGE_INDEX_VERSION]),
(catch ets:delete(?PACKAGE_TABLE)),
new_package_table()
end;
_ ->
new_package_table()
end.
handle_missing_package(PkgKey, Repo, State, Fun) ->
Name =
case PkgKey of
{N, Vsn, _Repo} ->
?DEBUG("Package ~ts-~ts not found. Fetching registry updates for "
"package and trying again...", [N, Vsn]),
N;
_ ->
?DEBUG("Package ~p not found. Fetching registry updates for "
"package and trying again...", [PkgKey]),
PkgKey
end,
update_package(Name, Repo, State),
try
Fun(State)
catch
_:_ ->
%% Even after an update the package is still missing, time to error out
throw(?PRV_ERROR({missing_package, PkgKey}))
end.
registry_dir(State) ->
CacheDir = rebar_dir:global_cache_dir(rebar_state:opts(State)),
RegistryDir = filename:join([CacheDir, "hex"]),
case filelib:ensure_dir(filename:join(RegistryDir, "placeholder")) of
ok -> ok;
{error, Posix} when Posix == eaccess; Posix == enoent ->
?ABORT("Could not write to ~p. Please ensure the path is writeable.",
[RegistryDir])
end,
{ok, RegistryDir}.
-spec package_dir(rebar_hex_repos:repo(), rebar_state:t()) -> {ok, file:filename_all()}.
package_dir(Repo, State) ->
{ok, RegistryDir} = registry_dir(State),
RepoName = maps:get(name, Repo),
PackageDir = filename:join([RegistryDir, rebar_utils:to_list(RepoName), "packages"]),
ok = filelib:ensure_dir(filename:join(PackageDir, "placeholder")),
{ok, PackageDir}.
%% Hex supports use of ~> to specify the version required for a dependency.
%% Since rebar3 requires exact versions to choose from we find the highest
%% available version of the dep that passes the constraint.
%% `~>` will never include pre-release versions of its upper bound.
%% It can also be used to set an upper bound on only the major
%% version part. See the table below for `~>` requirements and
%% their corresponding translation.
%% `~>` | Translation
%% :------------- | :---------------------
%% `~> 2.0.0` | `>= 2.0.0 and < 2.1.0`
%% `~> 2.1.2` | `>= 2.1.2 and < 2.2.0`
%% `~> 2.1.3-dev` | `>= 2.1.3-dev and < 2.2.0`
%% `~> 2.0` | `>= 2.0.0 and < 3.0.0`
%% `~> 2.1` | `>= 2.1.0 and < 3.0.0`
find_highest_matching(Dep, Constraint, Repo, Table, State) ->
try find_highest_matching_(Dep, Constraint, Repo, Table, State) of
none ->
handle_missing_package(Dep, Repo, State,
fun(State1) ->
find_highest_matching_(Dep, Constraint, Repo, Table, State1)
end);
Result ->
Result
catch
_:_ ->
handle_missing_package(Dep, Repo, State,
fun(State1) ->
find_highest_matching_(Dep, Constraint, Repo, Table, State1)
end)
end.
find_highest_matching_(Dep, Constraint, #{name := Repo}, Table, State) ->
try get_package_versions(Dep, Constraint, Repo, Table, State) of
[Vsn] ->
handle_single_vsn(Vsn, Constraint);
Vsns ->
case handle_vsns(Constraint, Vsns) of
none ->
none;
FoundVsn ->
{ok, FoundVsn}
end
catch
error:badarg ->
none
end.
handle_vsns(Constraint, Vsns) ->
lists:foldl(fun(Version, Highest) ->
case ec_semver:pes(Version, Constraint) andalso
(Highest =:= none orelse ec_semver:gt(Version, Highest)) of
true ->
Version;
false ->
Highest
end
end, none, Vsns).
handle_single_vsn(Vsn, Constraint) ->
case ec_semver:pes(Vsn, Constraint) of
true ->
{ok, Vsn};
false ->
none
end.
verify_table(State) ->
ets:info(?PACKAGE_TABLE, named_table) =:= true orelse load_and_verify_version(State).
parse_deps(Deps) ->
[{maps:get(app, D, Name), {pkg, Name, Constraint, undefined, undefined}}
|| D=#{package := Name,
requirement := Constraint} <- Deps].
parse_checksum(<<Checksum:256/big-unsigned>>) ->
list_to_binary(
rebar_string:uppercase(
lists:flatten(io_lib:format("~64.16.0b", [Checksum]))));
parse_checksum(Checksum) ->
Checksum.
update_package(Name, RepoConfig=#{name := Repo}, State) ->
?MODULE:verify_table(State),
?DEBUG("Getting definition for package ~ts from repo ~ts",
[Name, rebar_hex_repos:format_repo(RepoConfig)]),
try r3_hex_repo:get_package(get_package_repo_config(RepoConfig), Name) of
{ok, {200, _Headers, Releases}} ->
_ = insert_releases(Name, Releases, Repo, ?PACKAGE_TABLE),
{ok, RegistryDir} = rebar_packages:registry_dir(State),
PackageIndex = filename:join(RegistryDir, ?INDEX_FILE),
case ets:tab2file(?PACKAGE_TABLE, PackageIndex) of
ok -> ok;
{error, Error} ->
?WARN("Failed to update package index at ~p: ~p", [PackageIndex, Error])
end;
{error, unverified} ->
?WARN(unverified_repo_message(), [Repo]),
fail;
Error ->
?DEBUG("Hex get_package request failed: ~p", [Error]),
%% TODO: add better log message. r3_hex_core should export a format_error
?WARN("Failed to update package ~ts from repo ~ts", [Name, Repo]),
fail
catch
_:Exception ->
?DEBUG("hex_repo:get_package failed for package ~p: ~p", [Name, Exception]),
fail
end.
get_package_repo_config(RepoConfig=#{mirror_of := _}) ->
get_package_repo_config(maps:remove(mirror_of, RepoConfig));
get_package_repo_config(RepoConfig=#{name := _}) ->
get_package_repo_config(maps:remove(name, RepoConfig));
get_package_repo_config(RepoConfig) ->
RepoConfig.
unverified_repo_message() ->
"The registry repository ~ts uses a record format that has been deprecated for "
"security reasons. The repository should be updated in order to be safer. "
"You can disable this check by setting REBAR_NO_VERIFY_REPO_ORIGIN=1".
insert_releases(Name, Releases, Repo, Table) ->
[true = ets:insert(Table,
#package{key={Name, ec_semver:parse(Version), Repo},
inner_checksum=parse_checksum(InnerChecksum),
outer_checksum=parse_checksum(OuterChecksum),
retired=maps:get(retired, Release, false),
dependencies=parse_deps(Dependencies)})
|| Release=#{inner_checksum := InnerChecksum,
outer_checksum := OuterChecksum,
version := Version,
dependencies := Dependencies} <- Releases].
-spec resolve_version(unicode:unicode_binary(), unicode:unicode_binary() | undefined,
binary() | undefined,
binary() | undefined,
ets:tab(), rebar_state:t())
-> {error, {invalid_vsn, unicode:unicode_binary()}} |
not_found |
{ok, #package{}, map()}.
%% if checksum is defined search for any matching repo matching pkg-vsn and checksum
resolve_version(Dep, DepVsn, _OldHash, Hash, HexRegistry, State) when is_binary(Hash) ->
Resources = rebar_state:resources(State),
#{repos := RepoConfigs} = rebar_resource_v2:find_resource_state(pkg, Resources),
RepoNames = [RepoName || #{name := RepoName} <- RepoConfigs],
%% allow retired packages when we have a checksum
case get_package(Dep, DepVsn, Hash, RepoNames, HexRegistry, State) of
{ok, Package=#package{key={_, _, RepoName}}} ->
{ok, RepoConfig} = rebar_hex_repos:get_repo_config(RepoName, RepoConfigs),
{ok, Package, RepoConfig};
_ ->
Fun = fun(Repo) ->
case resolve_version_(Dep, DepVsn, Repo, HexRegistry, State) of
none ->
not_found;
{ok, Vsn} ->
get_package(Dep, Vsn, Hash, [Repo], HexRegistry, State)
end
end,
handle_missing_no_exception(Fun, Dep, State)
end;
resolve_version(Dep, undefined, _OldHash, Hash, HexRegistry, State) ->
Fun = fun(Repo) ->
case highest_matching(Dep, {0,{[],[]}}, Repo, HexRegistry, State) of
none ->
not_found;
{ok, Vsn} ->
get_package(Dep, Vsn, Hash, [Repo], HexRegistry, State)
end
end,
handle_missing_no_exception(Fun, Dep, State);
resolve_version(Dep, DepVsn, _OldHash, Hash, HexRegistry, State) ->
case valid_vsn(DepVsn) of
false ->
{error, {invalid_vsn, DepVsn}};
_ ->
Fun = fun(Repo) ->
case resolve_version_(Dep, DepVsn, Repo, HexRegistry, State) of
none ->
not_found;
{ok, Vsn} ->
get_package(Dep, Vsn, Hash, [Repo], HexRegistry, State)
end
end,
handle_missing_no_exception(Fun, Dep, State)
end.
check_all_repos(Fun, RepoConfigs) ->
ec_lists:search(fun(#{name := R}) ->
Fun(R)
end, RepoConfigs).
handle_missing_no_exception(Fun, Dep, State) ->
Resources = rebar_state:resources(State),
#{repos := RepoConfigs} = rebar_resource_v2:find_resource_state(pkg, Resources),
%% first check all repos in order for a local match
%% if none is found then we step through checking after updating the repo registry
case check_all_repos(Fun, RepoConfigs) of
not_found ->
ec_lists:search(fun(Config=#{name := R}) ->
case ?MODULE:update_package(Dep, Config, State) of
ok ->
Fun(R);
_ ->
not_found
end
end, RepoConfigs);
Result ->
Result
end.
resolve_version_(Dep, DepVsn, Repo, HexRegistry, State) ->
case DepVsn of
<<"~>", Vsn/binary>> ->
highest_matching(Dep, rm_ws(Vsn), Repo, HexRegistry, State);
<<">=", Vsn/binary>> ->
cmp(Dep, rm_ws(Vsn), Repo, HexRegistry, State, fun ec_semver:gte/2);
<<">", Vsn/binary>> ->
cmp(Dep, rm_ws(Vsn), Repo, HexRegistry, State, fun ec_semver:gt/2);
<<"<=", Vsn/binary>> ->
cmpl(Dep, rm_ws(Vsn), Repo, HexRegistry, State, fun ec_semver:lte/2);
<<"<", Vsn/binary>> ->
cmpl(Dep, rm_ws(Vsn), Repo, HexRegistry, State, fun ec_semver:lt/2);
<<"==", Vsn/binary>> ->
{ok, Vsn};
Vsn ->
{ok, Vsn}
end.
rm_ws(<<" ", R/binary>>) ->
ec_semver:parse(rm_ws(R));
rm_ws(R) ->
ec_semver:parse(R).
valid_vsn(Vsn) ->
%% Regepx from https://github.com/sindresorhus/semver-regex/blob/master/index.js
SemVerRegExp = "v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(\\.(0|[1-9][0-9]*))?"
"(-[0-9a-z-]+(\\.[0-9a-z-]+)*)?(\\+[0-9a-z-]+(\\.[0-9a-z-]+)*)?",
SupportedVersions = "^(>=?|<=?|~>|==)?\\s*" ++ SemVerRegExp ++ "$",
re:run(Vsn, SupportedVersions, [unicode]) =/= nomatch.
highest_matching(Dep, Vsn, Repo, HexRegistry, State) ->
find_highest_matching_(Dep, Vsn, #{name => Repo}, HexRegistry, State).
cmp(Dep, Vsn, Repo, HexRegistry, State, CmpFun) ->
case get_package_versions(Dep, Vsn, Repo, HexRegistry, State) of
[] ->
none;
Vsns ->
cmp_(undefined, Vsn, Vsns, CmpFun)
end.
cmp_(undefined, MinVsn, [], _CmpFun) ->
{ok, MinVsn};
cmp_(HighestDepVsn, _MinVsn, [], _CmpFun) ->
{ok, HighestDepVsn};
cmp_(BestMatch, MinVsn, [Vsn | R], CmpFun) ->
case CmpFun(Vsn, MinVsn) of
true ->
cmp_(Vsn, Vsn, R, CmpFun);
false ->
cmp_(BestMatch, MinVsn, R, CmpFun)
end.
%% We need to treat this differently since we want a version that is LOWER but
2022-01-08 14:36:34 +00:00
%% the highest possible one.
2021-12-27 19:09:21 +00:00
cmpl(Dep, Vsn, Repo, HexRegistry, State, CmpFun) ->
case get_package_versions(Dep, Vsn, Repo, HexRegistry, State) of
[] ->
none;
Vsns ->
cmpl_(undefined, Vsn, Vsns, CmpFun)
end.
cmpl_(undefined, MaxVsn, [], _CmpFun) ->
{ok, MaxVsn};
cmpl_(HighestDepVsn, _MaxVsn, [], _CmpFun) ->
{ok, HighestDepVsn};
cmpl_(undefined, MaxVsn, [Vsn | R], CmpFun) ->
case CmpFun(Vsn, MaxVsn) of
true ->
cmpl_(Vsn, MaxVsn, R, CmpFun);
false ->
cmpl_(undefined, MaxVsn, R, CmpFun)
end;
cmpl_(BestMatch, MaxVsn, [Vsn | R], CmpFun) ->
case CmpFun(Vsn, MaxVsn) of
true ->
case ec_semver:gte(Vsn, BestMatch) of
true ->
cmpl_(Vsn, MaxVsn, R, CmpFun);
false ->
cmpl_(BestMatch, MaxVsn, R, CmpFun)
end;
false ->
cmpl_(BestMatch, MaxVsn, R, CmpFun)
end.