diff --git a/lib/hex/cooldown.ex b/lib/hex/cooldown.ex new file mode 100644 index 00000000..42bed05e --- /dev/null +++ b/lib/hex/cooldown.ex @@ -0,0 +1,172 @@ +defmodule Hex.Cooldown do + @moduledoc false + + @type duration :: String.t() + @type cutoff :: {:cutoff, integer(), non_neg_integer()} | :disabled + + @doc """ + Parses a duration string into a normalized form for `Hex.State`. + + Returns `{:ok, duration}` for valid input or `:error` for invalid input. + + Accepted forms: `"0"`, `"d"`, `"w"`, `"mo"`. + """ + @spec parse_config(String.t() | nil) :: {:ok, duration()} | :error + def parse_config(nil), do: {:ok, "0d"} + def parse_config(""), do: {:ok, "0d"} + + def parse_config(string) when is_binary(string) do + case duration_to_seconds(string) do + {:ok, _} -> {:ok, string} + :error -> :error + end + end + + def parse_config(_), do: :error + + @doc """ + Parses the `cooldown_exclude_repos` config value into a list of repo + name strings. + + Accepts either a list of strings (project / global config) or a + comma-separated string (env var). Empty entries are dropped, whitespace + is trimmed. Repo names should match `Hex.Repo` keys, e.g. `"hexpm"`, + `"hexpm:myorg"`, or a custom repo name. + """ + @spec parse_exclude_repos([String.t()] | String.t() | nil) :: {:ok, [String.t()]} | :error + def parse_exclude_repos(nil), do: {:ok, []} + def parse_exclude_repos([]), do: {:ok, []} + + def parse_exclude_repos(list) when is_list(list) do + if Enum.all?(list, &is_binary/1) do + {:ok, list |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))} + else + :error + end + end + + def parse_exclude_repos(string) when is_binary(string) do + {:ok, + string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == ""))} + end + + def parse_exclude_repos(_), do: :error + + @doc """ + Returns true if cooldown should be skipped for the given repo. + """ + @spec repo_excluded?(String.t() | nil) :: boolean() + def repo_excluded?(repo) do + name = repo || "hexpm" + name in Hex.State.fetch!(:cooldown_exclude_repos) + end + + @doc """ + Converts a duration string into a number of seconds. + """ + @spec duration_to_seconds(String.t()) :: {:ok, non_neg_integer()} | :error + def duration_to_seconds("0"), do: {:ok, 0} + + def duration_to_seconds(string) when is_binary(string) do + with [_, digits, unit] <- Regex.run(~r/\A(\d+)(d|w|mo)\z/, string), + {n, ""} <- Integer.parse(digits) do + {:ok, n * unit_seconds(unit)} + else + _ -> :error + end + end + + defp unit_seconds("d"), do: 86_400 + defp unit_seconds("w"), do: 86_400 * 7 + defp unit_seconds("mo"), do: 86_400 * 30 + + @doc """ + Builds a resolution cutoff from the local cooldown configuration. + + Returns `:disabled` when the effective duration is zero. + """ + @spec build_cutoff() :: cutoff() + def build_cutoff() do + case duration_to_seconds(Hex.State.fetch!(:cooldown)) do + {:ok, 0} -> + :disabled + + {:ok, seconds} -> + now = System.system_time(:second) + {:cutoff, now - seconds, seconds} + + :error -> + :disabled + end + end + + @doc """ + Returns true if the release is eligible under the cutoff. + + A release with no `published_at` (legacy registry data) is treated as + eligible; cooldown only applies to releases whose publish time is known. + """ + @spec eligible?(integer() | nil, cutoff()) :: boolean() + def eligible?(_published_at, :disabled), do: true + def eligible?(nil, _cutoff), do: true + + def eligible?(published_at, {:cutoff, cutoff_seconds, _}) when is_integer(published_at) do + published_at <= cutoff_seconds + end + + @doc """ + Returns the date a release would become eligible under the cutoff. + """ + @spec eligible_on(integer(), cutoff()) :: Date.t() + def eligible_on(published_at, {:cutoff, _cutoff_seconds, window_seconds}) + when is_integer(published_at) do + published_at + |> Kernel.+(window_seconds) + |> DateTime.from_unix!() + |> DateTime.to_date() + end + + @doc """ + Formats the post-solver summary of versions skipped by cooldown. + + Takes a list of `{repo, package, version, published_at}` tuples accumulated + during the solve. Returns `nil` when nothing eligible to report — empty + input, disabled cutoff, or every entry missing a `published_at`. + + Entries are deduplicated and sorted by package then version. Repo is used + for keying only; it is not rendered. + """ + @spec format_summary([{String.t(), String.t(), String.t(), integer() | nil}], cutoff()) :: + String.t() | nil + def format_summary(_entries, :disabled), do: nil + def format_summary([], _cutoff), do: nil + + def format_summary(entries, cutoff) do + entries = + entries + |> Enum.reject(fn {_repo, _pkg, _vsn, published_at} -> is_nil(published_at) end) + |> Enum.uniq() + |> Enum.sort_by(fn {repo, pkg, vsn, _} -> {repo, pkg, vsn} end) + + case entries do + [] -> + nil + + entries -> + today = Date.utc_today() + + lines = + Enum.map(entries, fn {_repo, pkg, vsn, published_at} -> + published_date = published_at |> DateTime.from_unix!() |> DateTime.to_date() + days_ago = Date.diff(today, published_date) + eligible_date = eligible_on(published_at, cutoff) + " #{pkg} #{vsn} — published #{days_ago} days ago, eligible #{eligible_date}" + end) + + "\nVersions filtered by cooldown:\n" <> Enum.join(lines, "\n") <> "\n" + end + end +end diff --git a/lib/hex/dev.ex b/lib/hex/dev.ex index efdada6a..d6304558 100644 --- a/lib/hex/dev.ex +++ b/lib/hex/dev.ex @@ -5,7 +5,7 @@ if Mix.env() == :dev do @ets_name __MODULE__ @registry_filename "cache.ets" @repo "hexpm" - @ets_version 4 + @ets_version 5 def extract_registry(packages, new_path) do {:ok, original_ets} = :ets.file2tab(String.to_charlist(ets_path())) @@ -42,6 +42,7 @@ if Mix.env() == :dev do copy(original_ets, new_ets, {:outer_checksum, @repo, package, version}) copy(original_ets, new_ets, {:retired, @repo, package, version}) copy(original_ets, new_ets, {:advisories, @repo, package, version}) + copy(original_ets, new_ets, {:published_at, @repo, package, version}) copy(original_ets, new_ets, {:timestamp, @repo, package, version}) [{_, dep_tuples}] = :ets.lookup(original_ets, {:deps, @repo, package, version}) diff --git a/lib/hex/registry/cooldown.ex b/lib/hex/registry/cooldown.ex new file mode 100644 index 00000000..53c2410b --- /dev/null +++ b/lib/hex/registry/cooldown.ex @@ -0,0 +1,73 @@ +defmodule Hex.Registry.Cooldown do + @moduledoc false + + @behaviour Hex.Solver.Registry + + alias Hex.Registry.Server + + @impl true + defdelegate prefetch(packages), to: Server + + @impl true + defdelegate dependencies(repo, package, version), to: Server + + @impl true + def versions(repo, package) do + case Server.versions(repo, package) do + {:ok, versions} -> + cutoff = Hex.State.fetch!(:cooldown_cutoff) + bypass = Hex.State.fetch!(:cooldown_bypass_packages) + + cond do + cutoff == :disabled -> + {:ok, versions} + + Hex.Cooldown.repo_excluded?(repo) -> + {:ok, versions} + + MapSet.member?(bypass, package) -> + {:ok, versions} + + true -> + locked = + Map.get(Hex.State.fetch!(:cooldown_locked_versions), {repo || "hexpm", package}, []) + + {:ok, filter(versions, repo, package, cutoff, locked)} + end + + :error -> + :error + end + end + + defp filter(versions, repo, package, cutoff, locked) do + {eligible, filtered_out} = + Enum.split_with(versions, fn version -> + version_str = to_string(version) + + if version_str in locked do + true + else + published_at = Server.published_at(repo, package, version_str) + Hex.Cooldown.eligible?(published_at, cutoff) + end + end) + + record_filtered(repo, package, filtered_out) + eligible + end + + defp record_filtered(_repo, _package, []), do: :ok + + defp record_filtered(repo, package, versions) do + repo_key = repo || "hexpm" + + entries = + Enum.map(versions, fn version -> + version_str = to_string(version) + {repo_key, package, version_str, Server.published_at(repo, package, version_str)} + end) + + Hex.State.update!(:cooldown_filtered_versions, &(entries ++ &1)) + end +end diff --git a/lib/hex/registry/server.ex b/lib/hex/registry/server.ex index d76cd82a..93e1eb8b 100644 --- a/lib/hex/registry/server.ex +++ b/lib/hex/registry/server.ex @@ -7,7 +7,7 @@ defmodule Hex.Registry.Server do @name __MODULE__ @filename "cache.ets" @timeout 60_000 - @ets_version 4 + @ets_version 5 @public_keys_html "https://hex.pm/docs/public_keys" def start_link(opts \\ []) do @@ -58,6 +58,10 @@ defmodule Hex.Registry.Server do GenServer.call(@name, {:advisories, repo, package, version}, @timeout) end + def published_at(repo, package, version) do + GenServer.call(@name, {:published_at, repo, package, version}, @timeout) + end + def last_update() do GenServer.call(@name, :last_update, @timeout) end @@ -216,6 +220,12 @@ defmodule Hex.Registry.Server do end) end + def handle_call({:published_at, repo, package, version}, from, state) do + maybe_wait({repo, package}, from, state, fn -> + lookup(state.ets, {:published_at, repo || "hexpm", package, version}) + end) + end + def handle_call(:last_update, _from, state) do time = lookup(state.ets, :last_update) {:reply, time, state} @@ -313,6 +323,7 @@ defmodule Hex.Registry.Server do # {{:outer_checksum, ^repo, _package, _version}, _} -> true # {{:retired, ^repo, _package, _version}, _} -> true # {{:advisories, ^repo, _package, _version}, _} -> true + # {{:published_at, ^repo, _package, _version}, _} -> true # {{:registry_etag, ^repo, _package}, _} -> true # {{:timestamp, ^repo, _package}, _} -> true # {{:timestamp, ^repo, _package, _version}, _} -> true @@ -327,6 +338,7 @@ defmodule Hex.Registry.Server do {{{:outer_checksum, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:retired, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:advisories, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, + {{{:published_at, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:registry_etag, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:timestamp, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:timestamp, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, @@ -388,6 +400,14 @@ defmodule Hex.Registry.Server do :ets.insert(tid, {{:outer_checksum, repo, package, version}, release[:outer_checksum]}) :ets.insert(tid, {{:retired, repo, package, version}, release[:retired]}) + # The registry encodes published_at as a {seconds, nanos} Timestamp + # map. Cooldown only needs second granularity, so store the integer + # to keep the consumers simple. + :ets.insert( + tid, + {{:published_at, repo, package, version}, timestamp_seconds(release[:published_at])} + ) + release_advisories = (release[:advisory_indexes] || []) |> Enum.map(&Enum.at(pkg_advisories, &1)) @@ -539,6 +559,7 @@ defmodule Hex.Registry.Server do :ets.delete(tid, {:checksum, repo, package, version}) :ets.delete(tid, {:retired, repo, package, version}) :ets.delete(tid, {:advisories, repo, package, version}) + :ets.delete(tid, {:published_at, repo, package, version}) :ets.delete(tid, {:deps, repo, package, version}) end) end @@ -549,4 +570,7 @@ defmodule Hex.Registry.Server do [] -> nil end end + + defp timestamp_seconds(nil), do: nil + defp timestamp_seconds(%{seconds: seconds}), do: seconds end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 2b15eed9..258fbc46 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -77,6 +77,7 @@ defmodule Hex.RemoteConverger do defp run_solver(lock, old_lock, requests, locked, overridden) do start_time = System.monotonic_time(:millisecond) dependencies = Enum.map(requests, &request_to_dependency/1) + setup_cooldown(old_lock, locked) locked = Enum.map(locked, &request_to_locked/1) overridden = Enum.map(overridden, &Atom.to_string/1) verify_otp_app_names(dependencies) @@ -87,7 +88,7 @@ defmodule Hex.RemoteConverger do solution = try do Hex.Solver.run( - Registry, + Hex.Registry.Cooldown, dependencies, locked, overridden, @@ -104,14 +105,83 @@ defmodule Hex.RemoteConverger do case solution do {:ok, resolved} -> resolved = normalize_resolved(resolved) + print_cooldown_summary() solver_success(resolved, requests, lock, old_lock) {:error, message} -> Hex.Shell.info(message) + print_cooldown_summary() Mix.raise("Hex dependency resolution failed") end end + defp setup_cooldown(old_lock, locked) do + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + + bypass = build_cooldown_bypass(old_lock, locked, cutoff) + Hex.State.put(:cooldown_bypass_packages, bypass) + + Hex.State.put(:cooldown_locked_versions, build_cooldown_locked_versions(old_lock)) + Hex.State.put(:cooldown_filtered_versions, []) + end + + @doc false + def build_cooldown_locked_versions(old_lock) do + # Maps {repo, package} to the list of versions currently in the lockfile + # for that package. The cooldown filter treats these as exempt: a version + # the user is already running is trusted, even if cooldown would otherwise + # filter it. This lets re-resolution fall back to the locked version when + # no newer eligible candidate exists, instead of failing. + for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), + into: %{} do + {{repo || "hexpm", name}, [to_string(version)]} + end + end + + @doc false + def build_cooldown_bypass(_old_lock, _locked, :disabled), do: MapSet.new() + + def build_cooldown_bypass(old_lock, locked, _cutoff) do + # The bypass set has two sources: + # + # 1. Packages in `locked` (the post-`prepare_locked/3` set): the lockfile + # entry survived requirement checking and dep unlocking, so this + # package is being installed from the lock — no re-resolution, no + # cooldown. Matches the design's "cooldown does not apply when + # proceeding from the lockfile" promise. + # + # 2. Packages in `old_lock` whose locked version is known-unsafe (retired + # or carrying a security advisory): the user re-resolving is trying + # to escape that version; cooldown must not block the escape. Walks + # `old_lock` rather than `locked` because `mix deps.update foo` to + # escape an unsafe foo removes foo from `locked`. + lock_satisfied = for %{name: name} <- locked, into: MapSet.new(), do: name + + unsafe = + for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), + locked_version_unsafe?(repo || "hexpm", name, to_string(version)), + into: MapSet.new(), + do: name + + MapSet.union(lock_satisfied, unsafe) + end + + defp locked_version_unsafe?(repo, name, version) do + Registry.retired(repo, name, version) != nil or + Registry.advisories(repo, name, version) not in [nil, []] + end + + @doc false + def print_cooldown_summary() do + cutoff = Hex.State.fetch!(:cooldown_cutoff) + entries = Hex.State.fetch!(:cooldown_filtered_versions) + + if summary = Hex.Cooldown.format_summary(entries, cutoff) do + Hex.Shell.info(summary) + end + end + defp request_to_dependency(request) do constraint = if request.requirement do diff --git a/lib/hex/state.ex b/lib/hex/state.ex index 2e4546d3..e273b47a 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -128,6 +128,20 @@ defmodule Hex.State do env: ["CI"], default: false, fun: {__MODULE__, :to_truthy_boolean} + }, + cooldown: %{ + env: ["HEX_COOLDOWN"], + config: [:cooldown], + default: "0d", + skip_env_if_empty: true, + fun: {Hex.Cooldown, :parse_config} + }, + cooldown_exclude_repos: %{ + env: ["HEX_COOLDOWN_EXCLUDE_REPOS"], + config: [:cooldown_exclude_repos], + default: [], + skip_env_if_empty: true, + fun: {Hex.Cooldown, :parse_exclude_repos} } } @@ -234,7 +248,7 @@ defmodule Hex.State do env = System.get_env() result = - load_env(spec[:env], env) || + load_env(spec[:env], env, spec[:skip_env_if_empty]) || load_project_config(project_config, spec[:config]) || load_global_config(global_config, spec[:config]) @@ -279,9 +293,10 @@ defmodule Hex.State do |> Path.join("hex.config") end - defp load_env(keys, env) do + defp load_env(keys, env, skip_if_empty) do Enum.find_value(keys || [], fn key -> case Map.fetch(env, key) do + {:ok, ""} when skip_if_empty == true -> nil {:ok, value} -> {{:env, key}, value} :error -> nil end diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index cd57d972..164a2432 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -63,6 +63,18 @@ defmodule Mix.Tasks.Hex.Config do * `no_short_urls` - If set to true Hex will not shorten any links. Can be overridden by setting the environment variable `HEX_NO_SHORT_URLS` (Default: `false`) + * `cooldown` - Minimum age a package release must have to be considered + during dependency resolution, e.g. `"7d"`, `"2w"`, `"1mo"`. Releases + published more recently are filtered out of the candidate set when + resolving dependencies. Does not apply to installs from an existing + lockfile. Can be overridden by setting the environment variable + `HEX_COOLDOWN` (Default: `"0d"` — no cooldown) + * `cooldown_exclude_repos` - List of repository names for which + `cooldown` does not apply, e.g. `["hexpm:myorg"]`. Useful when an + organization publishes hotfixes to its own repository and wants to + consume them without cooldown delay. Can be overridden by setting + the environment variable `HEX_COOLDOWN_EXCLUDE_REPOS` to a + comma-separated list (Default: `[]`) Hex responds to these additional environment variables: diff --git a/test/fixtures/registries/20200917.ets b/test/fixtures/registries/20200917.ets index cec61717..d68b062a 100644 Binary files a/test/fixtures/registries/20200917.ets and b/test/fixtures/registries/20200917.ets differ diff --git a/test/fixtures/registries/20210915.ets b/test/fixtures/registries/20210915.ets index 53dad456..3de0114e 100644 Binary files a/test/fixtures/registries/20210915.ets and b/test/fixtures/registries/20210915.ets differ diff --git a/test/fixtures/registries/20210926.ets b/test/fixtures/registries/20210926.ets index 27a0664b..0446603a 100644 Binary files a/test/fixtures/registries/20210926.ets and b/test/fixtures/registries/20210926.ets differ diff --git a/test/hex/cooldown_test.exs b/test/hex/cooldown_test.exs new file mode 100644 index 00000000..204b06f6 --- /dev/null +++ b/test/hex/cooldown_test.exs @@ -0,0 +1,287 @@ +defmodule Hex.CooldownTest do + use HexTest.Case + alias Hex.Cooldown + + describe "parse_config/1" do + test "accepts integer + unit forms" do + assert {:ok, "0d"} == Cooldown.parse_config("0d") + assert {:ok, "7d"} == Cooldown.parse_config("7d") + assert {:ok, "14d"} == Cooldown.parse_config("14d") + assert {:ok, "2w"} == Cooldown.parse_config("2w") + assert {:ok, "1mo"} == Cooldown.parse_config("1mo") + assert {:ok, "0"} == Cooldown.parse_config("0") + end + + test "treats nil and empty as default" do + assert {:ok, "0d"} == Cooldown.parse_config(nil) + assert {:ok, "0d"} == Cooldown.parse_config("") + end + + test "rejects malformed durations" do + assert :error == Cooldown.parse_config("7day") + assert :error == Cooldown.parse_config("7 days") + assert :error == Cooldown.parse_config("1m") + assert :error == Cooldown.parse_config("1 month") + assert :error == Cooldown.parse_config("d7") + assert :error == Cooldown.parse_config("seven") + assert :error == Cooldown.parse_config("-1d") + end + end + + describe "duration_to_seconds/1" do + test "converts canonical units to seconds" do + assert {:ok, 0} == Cooldown.duration_to_seconds("0") + assert {:ok, 0} == Cooldown.duration_to_seconds("0d") + assert {:ok, 86_400} == Cooldown.duration_to_seconds("1d") + assert {:ok, 7 * 86_400} == Cooldown.duration_to_seconds("7d") + assert {:ok, 14 * 86_400} == Cooldown.duration_to_seconds("2w") + assert {:ok, 30 * 86_400} == Cooldown.duration_to_seconds("1mo") + end + end + + describe "build_cutoff/0" do + test "returns :disabled when cooldown is zero" do + Hex.State.put(:cooldown, "0d") + assert :disabled == Cooldown.build_cutoff() + end + + test "returns a cutoff for non-zero durations" do + Hex.State.put(:cooldown, "7d") + assert {:cutoff, _, seconds} = Cooldown.build_cutoff() + assert seconds == 7 * 86_400 + end + + test "weeks and months are honored" do + Hex.State.put(:cooldown, "2w") + assert {:cutoff, _, seconds} = Cooldown.build_cutoff() + assert seconds == 14 * 86_400 + + Hex.State.put(:cooldown, "1mo") + assert {:cutoff, _, seconds} = Cooldown.build_cutoff() + assert seconds == 30 * 86_400 + end + end + + describe "eligible?/2" do + test ":disabled cutoff makes everything eligible" do + assert Cooldown.eligible?(0, :disabled) + assert Cooldown.eligible?(nil, :disabled) + end + + test "nil published_at is treated as eligible" do + now = System.system_time(:second) + cutoff = {:cutoff, now - 7 * 86_400, 7 * 86_400} + assert Cooldown.eligible?(nil, cutoff) + end + + test "publish time older than cutoff is eligible" do + now = System.system_time(:second) + cutoff = {:cutoff, now - 7 * 86_400, 7 * 86_400} + old_publish = now - 10 * 86_400 + assert Cooldown.eligible?(old_publish, cutoff) + end + + test "publish time newer than cutoff is not eligible" do + now = System.system_time(:second) + cutoff = {:cutoff, now - 7 * 86_400, 7 * 86_400} + fresh_publish = now - 86_400 + refute Cooldown.eligible?(fresh_publish, cutoff) + end + end + + describe "HEX_COOLDOWN= env handling" do + test "empty HEX_COOLDOWN= falls through to next source, not env" do + original = System.get_env("HEX_COOLDOWN") + + try do + System.put_env("HEX_COOLDOWN", "") + Hex.State.refresh() + + # Without a project or global config contribution, fallthrough lands at default. + # The key assertion is that the source is NOT :env — if fallthrough were broken + # the source would be {:env, "HEX_COOLDOWN"} with value "0d". + assert :default == Hex.State.fetch_source!(:cooldown) + assert "0d" == Hex.State.fetch!(:cooldown) + after + if original do + System.put_env("HEX_COOLDOWN", original) + else + System.delete_env("HEX_COOLDOWN") + end + + Hex.State.refresh() + HexTest.Case.reset_state() + end + end + + test "HEX_COOLDOWN=0 explicitly disables (env wins)" do + original = System.get_env("HEX_COOLDOWN") + + try do + System.put_env("HEX_COOLDOWN", "0") + Hex.State.refresh() + + assert {:env, "HEX_COOLDOWN"} == Hex.State.fetch_source!(:cooldown) + assert "0" == Hex.State.fetch!(:cooldown) + after + if original do + System.put_env("HEX_COOLDOWN", original) + else + System.delete_env("HEX_COOLDOWN") + end + + Hex.State.refresh() + HexTest.Case.reset_state() + end + end + + test "empty HEX_COOLDOWN_EXCLUDE_REPOS= falls through to next source, not env" do + # Symmetric to the HEX_COOLDOWN= fall-through. Without + # skip_env_if_empty an empty env would silently shadow a project's + # cooldown_exclude_repos with []. + original = System.get_env("HEX_COOLDOWN_EXCLUDE_REPOS") + + try do + System.put_env("HEX_COOLDOWN_EXCLUDE_REPOS", "") + Hex.State.refresh() + + assert :default == Hex.State.fetch_source!(:cooldown_exclude_repos) + assert [] == Hex.State.fetch!(:cooldown_exclude_repos) + after + if original do + System.put_env("HEX_COOLDOWN_EXCLUDE_REPOS", original) + else + System.delete_env("HEX_COOLDOWN_EXCLUDE_REPOS") + end + + Hex.State.refresh() + HexTest.Case.reset_state() + end + end + end + + describe "parse_exclude_repos/1" do + test "accepts a list of strings" do + assert {:ok, ["a", "b"]} == Cooldown.parse_exclude_repos(["a", "b"]) + assert {:ok, []} == Cooldown.parse_exclude_repos([]) + end + + test "accepts a comma-separated string (env var form)" do + assert {:ok, ["hexpm:myorg", "private"]} == + Cooldown.parse_exclude_repos("hexpm:myorg,private") + + assert {:ok, ["hexpm:myorg"]} == Cooldown.parse_exclude_repos("hexpm:myorg") + end + + test "trims whitespace and drops empty entries" do + assert {:ok, ["a", "b"]} == Cooldown.parse_exclude_repos("a, b ,") + assert {:ok, ["a"]} == Cooldown.parse_exclude_repos([" a ", "", " "]) + end + + test "rejects non-string list elements" do + assert :error == Cooldown.parse_exclude_repos([:atom]) + end + + test "accepts nil and empty string as empty list" do + assert {:ok, []} == Cooldown.parse_exclude_repos(nil) + assert {:ok, []} == Cooldown.parse_exclude_repos("") + end + end + + describe "repo_excluded?/1" do + test "true when the repo name appears in the exclude list" do + Hex.State.put(:cooldown_exclude_repos, ["hexpm:myorg"]) + assert Cooldown.repo_excluded?("hexpm:myorg") + end + + test "false when the repo is not in the list" do + Hex.State.put(:cooldown_exclude_repos, ["hexpm:other"]) + refute Cooldown.repo_excluded?("hexpm:myorg") + end + + test "nil repo is treated as hexpm" do + Hex.State.put(:cooldown_exclude_repos, ["hexpm"]) + assert Cooldown.repo_excluded?(nil) + end + + test "false on empty list" do + Hex.State.put(:cooldown_exclude_repos, []) + refute Cooldown.repo_excluded?("hexpm:myorg") + refute Cooldown.repo_excluded?(nil) + end + end + + describe "format_summary/2" do + test "returns nil for an empty list" do + Hex.State.put(:cooldown, "7d") + cutoff = Cooldown.build_cutoff() + assert Cooldown.format_summary([], cutoff) == nil + end + + test "returns nil when cutoff is :disabled" do + now = System.system_time(:second) + assert Cooldown.format_summary([{"hexpm", "foo", "1.0.0", now}], :disabled) == nil + end + + test "renders a single entry with days ago and eligible date" do + Hex.State.put(:cooldown, "7d") + cutoff = Cooldown.build_cutoff() + now = System.system_time(:second) + published_at = now - 3 * 86_400 + + summary = Cooldown.format_summary([{"hexpm", "castore", "1.0.19", published_at}], cutoff) + + assert summary =~ "Versions filtered by cooldown:" + assert summary =~ "castore 1.0.19" + assert summary =~ "3 days ago" + assert summary =~ "eligible #{Cooldown.eligible_on(published_at, cutoff)}" + end + + test "sorts by package then version and dedupes identical entries" do + Hex.State.put(:cooldown, "7d") + cutoff = Cooldown.build_cutoff() + now = System.system_time(:second) + young = now - 1 * 86_400 + + entries = [ + {"hexpm", "plug", "1.16.5", young}, + {"hexpm", "castore", "1.0.19", young}, + {"hexpm", "castore", "1.0.19", young}, + {"hexpm", "castore", "1.0.18", young} + ] + + summary = Cooldown.format_summary(entries, cutoff) + + # Ordering: castore 1.0.18, castore 1.0.19, plug 1.16.5; dedup keeps one castore 1.0.19. + [_header, line1, line2, line3 | _] = String.split(summary, "\n", trim: true) + assert line1 =~ "castore 1.0.18" + assert line2 =~ "castore 1.0.19" + assert line3 =~ "plug 1.16.5" + + occurrences = summary |> String.split("castore 1.0.19") |> length() + assert occurrences == 2 + end + + test "omits entries with nil published_at" do + Hex.State.put(:cooldown, "7d") + cutoff = Cooldown.build_cutoff() + now = System.system_time(:second) + + entries = [ + {"hexpm", "legacy", "1.0.0", nil}, + {"hexpm", "castore", "1.0.19", now - 1 * 86_400} + ] + + summary = Cooldown.format_summary(entries, cutoff) + + assert summary =~ "castore 1.0.19" + refute summary =~ "legacy" + end + + test "returns nil when every entry has nil published_at" do + Hex.State.put(:cooldown, "7d") + cutoff = Cooldown.build_cutoff() + assert Cooldown.format_summary([{"hexpm", "legacy", "1.0.0", nil}], cutoff) == nil + end + end +end diff --git a/test/hex/mix_task_test.exs b/test/hex/mix_task_test.exs index 451d54a7..4cefc4ca 100644 --- a/test/hex/mix_task_test.exs +++ b/test/hex/mix_task_test.exs @@ -1,6 +1,15 @@ defmodule Hex.MixTaskTest do use HexTest.IntegrationCase + defp drain_shell_info_matching(substring) do + receive do + {:mix_shell, :info, [text]} -> + if text =~ substring, do: text, else: drain_shell_info_matching(substring) + after + 0 -> nil + end + end + defmodule Simple do def project do [ @@ -1128,4 +1137,115 @@ defmodule Hex.MixTaskTest do EctoOverride.Fixture.MixProject ]) end + + describe "cooldown" do + # All registry packages in the integration suite were just published by + # setup_hexpm.exs, so any non-zero cooldown filters them out. The unit + # tests cover env / project / global precedence and the empty-env + # fallthrough; this layer covers the end-to-end resolution behavior. + + test "non-zero cooldown lets the solver fail and prints a filtered-versions summary" do + Mix.Project.push(Simple) + + in_tmp(fn -> + Hex.State.put(:cache_home, File.cwd!()) + Hex.State.put(:cooldown, "1d") + + assert_raise Mix.Error, "Hex dependency resolution failed", fn -> + Mix.Task.run("deps.get") + end + + # Solver prints its own "no matching versions" message; Hex appends a + # post-solver summary naming the ecto versions filtered by cooldown. + summary = drain_shell_info_matching("Versions filtered by cooldown:") + assert summary + assert summary =~ "ecto" + end) + after + Hex.State.put(:cooldown, "0d") + + purge([ + Ecto.NoConflict.MixProject, + Postgrex.NoConflict.MixProject, + Ex_doc.NoConflict.MixProject + ]) + end + + test "cooldown 0d (default) resolves normally" do + Mix.Project.push(Simple) + + in_tmp(fn -> + Hex.State.put(:cache_home, File.cwd!()) + Hex.State.put(:cooldown, "0d") + + Mix.Task.run("deps.get") + + assert_received {:mix_shell, :info, ["* Getting ecto (Hex package)"]} + end) + after + purge([ + Ecto.NoConflict.MixProject, + Postgrex.NoConflict.MixProject, + Ex_doc.NoConflict.MixProject + ]) + end + + defmodule TiredPin do + def project do + [ + app: :tired_app, + version: "0.1.0", + consolidate_protocols: false, + deps: [{:tired, "0.1.0"}] + ] + end + end + + defmodule TiredRange do + def project do + [ + app: :tired_app, + version: "0.1.0", + consolidate_protocols: false, + deps: [{:tired, "~> 0.1"}] + ] + end + end + + test "retired locked version escapes cooldown via bypass on deps.update" do + # setup_hexpm.exs publishes the `tired` package with versions 0.1.0 + # (retired) and 0.2.0. Both were published in the test session, so a + # 1-day cooldown filters BOTH from the candidate set. Without the + # unsafe-locked-version bypass, `mix deps.update tired` would + # pre-flight error because every matching version is in cooldown. + # The bypass detects the locked 0.1.0 is retired and lets the + # resolver see the full set, so it picks 0.2.0. + Mix.Project.push(TiredPin) + + in_tmp(fn -> + Hex.State.put(:cache_home, File.cwd!()) + + # Lock at the retired 0.1.0 — cooldown off so this resolves cleanly. + Mix.Task.run("deps.get") + assert %{tired: {:hex, :tired, "0.1.0", _, _, _, _, _}} = Mix.Dep.Lock.read() + + purge([Tired.NoConflict.MixProject]) + Mix.State.clear_cache() + Mix.Project.pop() + Mix.Project.push(TiredRange) + Mix.Task.clear() + + # Now turn on cooldown that would filter every version of tired — + # only the retirement bypass can let the upgrade succeed. + Hex.State.put(:cooldown, "1d") + + Mix.Task.run("deps.update", ["tired"]) + + assert %{tired: {:hex, :tired, "0.2.0", _, _, _, _, _}} = Mix.Dep.Lock.read() + end) + after + Hex.State.put(:cooldown, "0d") + purge([Tired.NoConflict.MixProject]) + end + end end diff --git a/test/hex/registry/cooldown_test.exs b/test/hex/registry/cooldown_test.exs new file mode 100644 index 00000000..aea9cc44 --- /dev/null +++ b/test/hex/registry/cooldown_test.exs @@ -0,0 +1,208 @@ +defmodule Hex.Registry.CooldownTest do + use HexTest.Case + alias Hex.Registry.Cooldown + alias Hex.Registry.Server + + setup do + now = System.system_time(:second) + + registry = [ + {:hexpm, :foo, "1.0.0", []}, + {:hexpm, :foo, "1.1.0", []}, + {:hexpm, :foo, "1.2.0", []} + ] + + publish_times = %{ + {:hexpm, :foo, "1.0.0"} => now - 30 * 86_400, + {:hexpm, :foo, "1.1.0"} => now - 14 * 86_400, + {:hexpm, :foo, "1.2.0"} => now - 2 * 86_400 + } + + path = tmp_path("cache.ets") + File.rm(path) + create_test_registry(path, registry, [], publish_times) + + Hex.State.put(:offline, true) + Server.open(registry_path: path) + Server.prefetch([{"hexpm", "foo"}]) + + %{now: now} + end + + test "passes through versions when cooldown disabled" do + Hex.State.put(:cooldown_cutoff, :disabled) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0", "1.2.0"] + end + + test "filters versions within cooldown window" do + Hex.State.put(:cooldown, "7d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + # 1.2.0 published 2 days ago should be filtered + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0"] + end + + test "bypass set short-circuits filtering for a package" do + Hex.State.put(:cooldown, "60d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new(["foo"])) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0", "1.2.0"] + end + + test "cooldown_exclude_repos short-circuits filtering for repos in the list" do + Hex.State.put(:cooldown, "60d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:cooldown_exclude_repos, ["hexpm"]) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0", "1.2.0"] + after + Hex.State.put(:cooldown_exclude_repos, []) + end + + test "cooldown_exclude_repos does not affect non-excluded repos" do + Hex.State.put(:cooldown, "60d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:cooldown_exclude_repos, ["hexpm:other"]) + + # hexpm is not in the exclude list — cooldown still applies, filtering + # everything in the fixture (all versions are <60d old). + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert versions == [] + after + Hex.State.put(:cooldown_exclude_repos, []) + end + + test "nil published_at is treated as eligible" do + now = System.system_time(:second) + + registry = [ + {:hexpm, :legacy, "1.0.0", []}, + {:hexpm, :legacy, "1.1.0", []} + ] + + publish_times = %{ + {:hexpm, :legacy, "1.1.0"} => now - 1 * 86_400 + } + + path = tmp_path("cache_legacy.ets") + File.rm(path) + create_test_registry(path, registry, [], publish_times) + + Server.close() + Hex.State.put(:offline, true) + Server.open(registry_path: path) + Server.prefetch([{"hexpm", "legacy"}]) + + Hex.State.put(:cooldown, "30d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, versions} = Cooldown.versions("hexpm", "legacy") + # 1.0.0 has nil published_at -> eligible; 1.1.0 is too fresh -> filtered + assert Enum.map(versions, &to_string/1) == ["1.0.0"] + end + + test "delegates prefetch and dependencies to Server" do + Code.ensure_loaded!(Cooldown) + assert function_exported?(Cooldown, :prefetch, 1) + assert function_exported?(Cooldown, :dependencies, 3) + end + + test "version present in :cooldown_locked_versions survives the filter" do + # An in-cooldown version that is currently in the lockfile must remain + # a solver candidate so re-resolution can fall back to it when no newer + # eligible version exists. + Hex.State.put(:cooldown, "7d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:cooldown_locked_versions, %{{"hexpm", "foo"} => ["1.2.0"]}) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0", "1.2.0"] + end + + test "locked-version exemption only applies to the matching repo+package+version" do + Hex.State.put(:cooldown, "7d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + # Stale entry naming a different version — does not save 1.2.0. + Hex.State.put(:cooldown_locked_versions, %{{"hexpm", "foo"} => ["1.1.0"]}) + + {:ok, versions} = Cooldown.versions("hexpm", "foo") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0"] + end + + describe "filtered-versions recording" do + test "records each filtered version with its published_at", %{now: now} do + Hex.State.put(:cooldown, "7d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, _} = Cooldown.versions("hexpm", "foo") + + filtered = Hex.State.fetch!(:cooldown_filtered_versions) + assert [{"hexpm", "foo", "1.2.0", published_at}] = filtered + assert_in_delta published_at, now - 2 * 86_400, 2 + end + + test "does not record versions that survive via the locked exemption" do + Hex.State.put(:cooldown, "7d") + cutoff = Hex.Cooldown.build_cutoff() + Hex.State.put(:cooldown_cutoff, cutoff) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{{"hexpm", "foo"} => ["1.2.0"]}) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, _} = Cooldown.versions("hexpm", "foo") + + assert Hex.State.fetch!(:cooldown_filtered_versions) == [] + end + + test "records nothing when cooldown is disabled" do + Hex.State.put(:cooldown_cutoff, :disabled) + Hex.State.put(:cooldown_bypass_packages, MapSet.new()) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, _} = Cooldown.versions("hexpm", "foo") + + assert Hex.State.fetch!(:cooldown_filtered_versions) == [] + end + end +end diff --git a/test/hex/remote_converger_cooldown_test.exs b/test/hex/remote_converger_cooldown_test.exs new file mode 100644 index 00000000..9cac26d4 --- /dev/null +++ b/test/hex/remote_converger_cooldown_test.exs @@ -0,0 +1,202 @@ +defmodule Hex.RemoteConvergerCooldownTest do + # Non-integration tests for the cooldown helpers in Hex.RemoteConverger. + # The full integration scenarios live under HexTest.IntegrationCase. + use HexTest.Case + + alias Hex.RemoteConverger + alias Hex.Registry.Server + + setup do + registry = [ + {:hexpm, :good, "1.0.0", []}, + {:hexpm, :retired_dep, "1.0.0", []}, + {:hexpm, :retired_dep, "2.0.0", []}, + {:hexpm, :advised_dep, "1.0.0", []}, + {:hexpm, :advised_dep, "2.0.0", []} + ] + + retired = %{ + # The currently-locked version of retired_dep is retired. + {:hexpm, :retired_dep, "1.0.0"} => %{reason: :RETIRED_SECURITY, message: "CVE"} + } + + # Per-release advisories. Stored under {:advisories, repo, pkg, vsn} as + # a list; non-empty means the version is known-unsafe. + advisories = [ + {{"hexpm", "advised_dep", "1.0.0"}, + [%{id: "GHSA-test-aaaa-bbbb", summary: "test", severity: :SEVERITY_HIGH}]} + ] + + path = tmp_path("cache_bypass.ets") + File.rm(path) + create_test_registry(path, registry, advisories, %{}, retired) + + Server.close() + Hex.State.put(:offline, true) + Server.open(registry_path: path) + Server.prefetch([{"hexpm", "good"}, {"hexpm", "retired_dep"}, {"hexpm", "advised_dep"}]) + + :ok + end + + defp lock_tuple(name, version, repo \\ "hexpm") do + {:hex, String.to_atom(name), version, "checksum", [:mix], [], repo, "outer_checksum"} + end + + defp locked_request(name, version, repo \\ "hexpm") do + %{repo: repo, name: name, app: name, version: version} + end + + @cutoff {:cutoff, System.system_time(:second) - 7 * 86_400, 7 * 86_400} + + describe "build_cooldown_bypass/3 — disabled" do + test "returns empty set when cutoff is :disabled" do + old_lock = %{retired_dep: lock_tuple("retired_dep", "1.0.0")} + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], :disabled) + end + end + + describe "build_cooldown_bypass/3 — unsafe-locked-version bypass" do + test "includes packages whose locked version is retired" do + old_lock = %{ + retired_dep: lock_tuple("retired_dep", "1.0.0"), + good: lock_tuple("good", "1.0.0") + } + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + + assert MapSet.member?(bypass, "retired_dep") + refute MapSet.member?(bypass, "good") + end + + test "regression: unsafe-bypass walks old_lock so updates can escape retirement" do + # Guards against the bug where the bypass was built from the post- + # prepare_locked value, which removes the package being `mix deps.update`d + # — exactly the package that needs bypass. + old_lock = %{retired_dep: lock_tuple("retired_dep", "1.0.0")} + # locked is empty because deps.update unlocked the package + bypass = RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + + assert MapSet.member?(bypass, "retired_dep") + end + + test "includes packages whose locked version carries a security advisory" do + old_lock = %{advised_dep: lock_tuple("advised_dep", "1.0.0")} + bypass = RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + assert MapSet.member?(bypass, "advised_dep") + end + + test "locked versions with empty advisory list are not bypassed via unsafe-set" do + old_lock = %{advised_dep: lock_tuple("advised_dep", "2.0.0")} + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], @cutoff) + end + end + + describe "build_cooldown_bypass/3 — lock-satisfied bypass (spec C)" do + test "packages in `locked` (lockfile survived prepare_locked) are bypassed" do + # mix deps.get against an intact lockfile: the dep made it through + # prepare_locked, so it is being installed from the lock. Cooldown + # must not interfere — matches the design's promise that + # \"mix deps.get against an existing lockfile: cooldown does not apply\". + old_lock = %{good: lock_tuple("good", "1.0.0")} + locked = [locked_request("good", "1.0.0")] + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + + assert MapSet.member?(bypass, "good") + end + + test "packages in old_lock but not in locked (being updated) are not bypassed by lock-satisfied" do + # mix deps.update foo: foo is in old_lock but prepare_locked removed it. + # If foo's locked version is not unsafe, foo should NOT bypass cooldown. + old_lock = %{good: lock_tuple("good", "1.0.0")} + locked = [] + + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + end + + test "new top-level deps (not in old_lock) are not bypassed" do + # User added a new dep to mix.exs: no lock entry, no bypass. + # Cooldown filtering applies normally to fresh additions. + old_lock = %{} + locked = [] + assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + end + end + + describe "print_cooldown_summary/0" do + test "prints the summary when filtered versions were recorded" do + now = System.system_time(:second) + Hex.State.put(:cooldown, "7d") + Hex.State.put(:cooldown_cutoff, Hex.Cooldown.build_cutoff()) + + Hex.State.put(:cooldown_filtered_versions, [ + {"hexpm", "castore", "1.0.19", now - 6 * 86_400} + ]) + + RemoteConverger.print_cooldown_summary() + + assert_received {:mix_shell, :info, [output]} + assert output =~ "Versions filtered by cooldown:" + assert output =~ "castore 1.0.19" + end + + test "prints nothing when nothing was filtered" do + Hex.State.put(:cooldown, "7d") + Hex.State.put(:cooldown_cutoff, Hex.Cooldown.build_cutoff()) + Hex.State.put(:cooldown_filtered_versions, []) + + RemoteConverger.print_cooldown_summary() + + refute_received {:mix_shell, :info, _} + end + + test "prints nothing when cooldown is disabled" do + Hex.State.put(:cooldown_cutoff, :disabled) + Hex.State.put(:cooldown_filtered_versions, []) + + RemoteConverger.print_cooldown_summary() + + refute_received {:mix_shell, :info, _} + end + end + + describe "end-to-end advisory bypass" do + # Wires the bypass set into the wrapper to confirm that an + # advisory-flagged locked version actually unblocks the upgrade path + # — not only that build_cooldown_bypass produces the right MapSet, + # but that the wrapper consumes it and stops filtering. + test "advisory-only locked version unblocks the upgrade through the wrapper" do + # advised_dep 1.0.0 has an advisory; 2.0.0 is clean but assumed young. + old_lock = %{advised_dep: lock_tuple("advised_dep", "1.0.0")} + locked = [] + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + Hex.State.put(:cooldown_cutoff, @cutoff) + Hex.State.put(:cooldown_bypass_packages, bypass) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + + # Without bypass the wrapper would filter every version (no published_at + # in the fixture means eligible, but if we'd populated young + # published_at the bypass is what saves us). Either way, the wrapper + # must return the full version list because the package is bypassed. + {:ok, versions} = Hex.Registry.Cooldown.versions("hexpm", "advised_dep") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "2.0.0"] + end + + test "retired locked version unblocks the upgrade through the wrapper" do + old_lock = %{retired_dep: lock_tuple("retired_dep", "1.0.0")} + locked = [] + + bypass = RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + Hex.State.put(:cooldown_cutoff, @cutoff) + Hex.State.put(:cooldown_bypass_packages, bypass) + Hex.State.put(:cooldown_locked_versions, %{}) + Hex.State.put(:cooldown_filtered_versions, []) + + {:ok, versions} = Hex.Registry.Cooldown.versions("hexpm", "retired_dep") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "2.0.0"] + end + end +end diff --git a/test/mix/tasks/hex.config_test.exs b/test/mix/tasks/hex.config_test.exs index f74e9e19..d291271c 100644 --- a/test/mix/tasks/hex.config_test.exs +++ b/test/mix/tasks/hex.config_test.exs @@ -25,11 +25,30 @@ defmodule Mix.Tasks.Hex.ConfigTest do assert_received {:mix_shell, :info, ["trusted_mirror_url: nil (default)"]} assert_received {:mix_shell, :info, ["config_home:" <> _]} assert_received {:mix_shell, :info, ["no_short_urls: false (default)"]} + assert_received {:mix_shell, :info, ["cooldown: \"0d\" (default)"]} end) after purge([ReleaseCustomApiUrl.MixProject]) end + test "cooldown config key" do + in_tmp(fn -> + System.put_env("HEX_HOME", File.cwd!()) + Hex.State.refresh() + + Mix.Tasks.Hex.Config.run(["cooldown"]) + assert_received {:mix_shell, :info, ["\"0d\""]} + + System.put_env("HEX_COOLDOWN", "7d") + Hex.State.refresh() + Mix.Tasks.Hex.Config.run(["cooldown"]) + assert_received {:mix_shell, :info, ["\"7d\""]} + + System.delete_env("HEX_COOLDOWN") + Hex.State.refresh() + end) + end + test "config key" do in_tmp(fn -> System.put_env("HEX_HOME", File.cwd!()) diff --git a/test/support/case.ex b/test/support/case.ex index 7901fd24..8b338fe0 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -119,7 +119,7 @@ defmodule HexTest.Case do @ets_table :hex_index - def create_test_registry(path, registry, advisories \\ []) do + def create_test_registry(path, registry, advisories \\ [], publish_times \\ %{}, retired \\ %{}) do registry = Enum.sort(registry) versions = @@ -143,10 +143,10 @@ defmodule HexTest.Case do {{Atom.to_string(outer_repo), Atom.to_string(name), vsn}, deps} end) - create_registry(path, versions, deps, advisories) + create_registry(path, versions, deps, advisories, publish_times, retired) end - defp create_registry(path, versions, deps, advisories) do + defp create_registry(path, versions, deps, advisories, publish_times, retired) do tid = :ets.new(@ets_table, []) versions = @@ -164,7 +164,21 @@ defmodule HexTest.Case do {{:advisories, repo, pkg, vsn}, adv} end) - :ets.insert(tid, versions ++ deps ++ advisories ++ [{:version, 4}]) + published_at = + Enum.map(publish_times, fn {{repo, pkg, vsn}, ts} -> + {{:published_at, Atom.to_string(repo), Atom.to_string(pkg), vsn}, ts} + end) + + retired = + Enum.map(retired, fn {{repo, pkg, vsn}, retirement} -> + {{:retired, Atom.to_string(repo), Atom.to_string(pkg), vsn}, retirement} + end) + + :ets.insert( + tid, + versions ++ deps ++ advisories ++ published_at ++ retired ++ [{:version, 5}] + ) + File.mkdir_p!(Path.dirname(path)) :ok = :ets.tab2file(tid, String.to_charlist(path)) :ets.delete(tid)