-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(<>) -> 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 %% the highest possible one. 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.