Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions lib/hex/cooldown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,51 @@ defmodule Hex.Cooldown do
defp unit_seconds("w"), do: 86_400 * 7
defp unit_seconds("mo"), do: 86_400 * 30

@doc """
Picks the strictest (longest) duration from a list of `{tag, duration}`
candidates. `nil` and `""` durations are treated as `"0d"`.

Returns the chosen `{tag, duration}` so callers can attribute the
decision to its source (e.g. `:local` vs `{repo, name}`).
"""
@spec strictest([{tag, String.t() | nil}]) :: {tag, String.t()} when tag: term()
def strictest(candidates) do
candidates
|> Enum.map(fn {tag, dur} -> {tag, normalize(dur), seconds(dur)} end)
|> Enum.max_by(&elem(&1, 2))
|> then(fn {tag, dur, _} -> {tag, dur} end)
end

defp normalize(nil), do: "0d"
defp normalize(""), do: "0d"
defp normalize(dur), do: dur

defp seconds(nil), do: 0
defp seconds(""), do: 0

defp seconds(dur) do
case duration_to_seconds(dur) do
{:ok, n} -> n
:error -> 0
end
end

@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
def build_cutoff(), do: build_cutoff(Hex.State.fetch!(:cooldown))

@doc """
Builds a resolution cutoff from a duration string.

`build_cutoff/0` is equivalent to `build_cutoff(Hex.State.fetch!(:cooldown))`.
"""
@spec build_cutoff(String.t() | nil) :: cutoff()
def build_cutoff(duration) do
case duration_to_seconds(duration || "0d") do
{:ok, 0} ->
:disabled

Expand Down
80 changes: 80 additions & 0 deletions lib/hex/policy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule Hex.Policy do
@moduledoc false

alias Hex.Policy.Sources
alias Hex.Registry.Server, as: Registry

@doc """
Reads the configured policy refs from all sources (project, env,
global), unions them, fetches each policy through the registry,
and returns the active set as a `%{ref => policy}` map.

Returns `{:error, :invalid_policy_config}` if any source has a
malformed value. Fetch failures with no usable cache raise through
the registry's standard fetch error path.
"""
@spec load_all() :: {:ok, %{Sources.ref() => map()}} | {:error, term()}
def load_all() do
case Sources.load_all() do
{:ok, []} ->
{:ok, %{}}

{:ok, refs} ->
Registry.open()
Registry.prefetch_policies(refs)

policies =
Enum.reduce(refs, %{}, fn {repo, name} = ref, acc ->
case Registry.policy(repo, name) do
{:ok, decoded} -> Map.put(acc, ref, decoded)
:error -> acc
end
end)

{:ok, policies}

:error ->
{:error, :invalid_policy_config}
end
end

@doc """
Returns the active policy set, lazy-loading and caching it in
`Hex.State` on first call.

When the remote converger has already populated `:policies` (the
normal `mix deps.get` path) this is a cheap state read. When called
standalone (e.g. from `mix hex.policy show`) and the configured
source list is non-empty it triggers the registry fetch and stores
the result for subsequent calls.
"""
@spec active() :: {:ok, %{Sources.ref() => map()}} | {:error, term()}
def active() do
loaded = Hex.State.fetch!(:policies)

cond do
loaded != %{} ->
{:ok, loaded}

configured_refs() == [] ->
{:ok, %{}}

true ->
case load_all() do
{:ok, policies_map} ->
Hex.State.put(:policies, policies_map)
{:ok, policies_map}

{:error, _} = err ->
err
end
end
end

defp configured_refs() do
case Sources.load_all() do
{:ok, refs} -> refs
:error -> []
end
end
end
128 changes: 128 additions & 0 deletions lib/hex/policy/diagnostics.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
defmodule Hex.Policy.Diagnostics do
@moduledoc false

alias Hex.Cooldown

@type filtered_entry :: %{
repo: String.t(),
package: String.t(),
version: String.t(),
blockers: [%{policy: map(), reason: term()}]
}

@doc """
Builds the resolution summary block. Returns `nil` when no policies
are loaded or nothing was filtered.

`policies` is a list of decoded policy maps; `filtered` is the
list of `%{repo, package, version, blockers}` entries recorded by
Hex.Registry.Policy.
"""
@spec resolution_summary([map()], [filtered_entry()], String.t() | nil) ::
String.t() | nil
def resolution_summary([], _filtered, _local_cooldown), do: nil

def resolution_summary(policies, filtered, local_cooldown) do
refs =
policies
|> Enum.map(fn p -> "#{p.repository}/#{p.name}" end)
|> Enum.sort()

header = "Active policies: #{Enum.join(refs, ", ")} (#{length(policies)})"

cooldown_line =
case Cooldown.strictest([
{:local, local_cooldown}
| Enum.map(policies, fn p -> {{p.repository, p.name}, Map.get(p, :cooldown)} end)
]) do
{_source, "0d"} ->
nil

{source, duration} ->
source_str =
case source do
:local -> "local"
{repo, name} -> "#{repo}/#{name}"
end

"Effective cooldown: #{duration} (#{source_str})"
end

hidden_line =
if filtered != [] do
"Policies hid #{length(filtered)} candidate versions"
end

per_policy_lines = per_policy_breakdown(filtered, policies)

[header, cooldown_line, hidden_line | per_policy_lines]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end

defp per_policy_breakdown(filtered, policies) do
for p <- policies do
blocks =
Enum.count(filtered, fn entry ->
Enum.any?(entry.blockers, fn b ->
b.policy.repository == p.repository and b.policy.name == p.name
end)
end)

if blocks > 0 do
" #{p.repository}/#{p.name}: #{blocks} blocked"
end
end
|> Enum.reject(&is_nil/1)
end

@doc """
Renders a Note: block to append to a solver failure when active
policies hid candidate versions.

Returns `nil` if there's nothing relevant to say.
"""
@spec failure_note([filtered_entry()]) :: String.t() | nil
def failure_note([]), do: nil

def failure_note(filtered) do
by_package = Enum.group_by(filtered, fn entry -> {entry.repo, entry.package} end)

blocks =
Enum.map(by_package, fn {{_repo, package}, entries} ->
lines =
Enum.map(entries, fn entry ->
attribution = entry.blockers |> Enum.map(&format_blocker/1) |> Enum.join(", ")
" #{package} #{entry.version} — #{attribution}"
end)

"Note: active policies hide #{length(entries)} versions of \"#{package}\":\n" <>
Enum.join(lines, "\n")
end)

Enum.join(blocks, "\n\n")
end

@doc """
Formats a `Hex.Policy.load_all/0` error for `Mix.raise/1`.
"""
@spec format_load_error(term()) :: String.t()
def format_load_error(:invalid_policy_config) do
"Policy configuration is invalid. Check the `:policy` key in mix.exs, " <>
"the HEX_POLICY env var, and `mix hex.config policy`."
end

def format_load_error(other), do: "Policy loading failed: #{inspect(other)}"

defp format_blocker(%{policy: p, reason: {:advisory, sev}}) do
"#{p.repository}/#{p.name} (advisory ≥ #{Hex.Utils.advisory_severity(sev)})"
end

defp format_blocker(%{policy: p, reason: {:retirement, r}}) do
"#{p.repository}/#{p.name} (retirement: #{Hex.Utils.package_retirement_reason(r)})"
end

defp format_blocker(%{policy: p, reason: other}) do
"#{p.repository}/#{p.name} (#{inspect(other)})"
end
end
105 changes: 105 additions & 0 deletions lib/hex/policy/filter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule Hex.Policy.Filter do
@moduledoc false

alias Hex.Registry.Server

@severity_order [
:SEVERITY_NONE,
:SEVERITY_LOW,
:SEVERITY_MEDIUM,
:SEVERITY_HIGH,
:SEVERITY_CRITICAL
]

@type policy :: map()
@type release :: map()
@type reason :: {:advisory, atom()} | {:retirement, atom()}
@type blocker :: %{policy: policy(), reason: reason()}

@doc """
Classifies a single release against a single policy.

Returns `:allowed` or `{:blocked, [reason]}`.
"""
@spec classify(policy(), release(), keyword()) :: :allowed | {:blocked, [reason()]}
def classify(policy, release, _opts \\ []) do
reasons =
[]
|> add_advisory(policy, release)
|> add_retirement(policy, release)

if reasons == [], do: :allowed, else: {:blocked, reasons}
end

@doc """
Classifies a release against the active set of policies, ANDing across.

Returns `:allowed` (no policy blocks) or `{:blocked, [blocker]}` where
each blocker names the responsible policy and the reason it fired.
"""
@spec classify_set([policy()], release(), keyword()) ::
:allowed | {:blocked, [blocker()]}
def classify_set(policies, release, opts \\ []) do
blockers =
for policy <- policies,
{:blocked, reasons} <- [classify(policy, release, opts)],
reason <- reasons,
do: %{policy: policy, reason: reason}

if blockers == [], do: :allowed, else: {:blocked, blockers}
end

@doc """
Builds a release map suitable for `classify/3` and `classify_set/3` from
the registry.

Returned shape: `%{version, advisories, retired}`. Advisories carry the
atom severities decoded from the registry; `advisories: []` covers the
legacy entries `Hex.Registry.Server.advisories/3` returns as `nil`.
"""
@spec release_from_registry(String.t() | nil, String.t(), term()) :: release()
def release_from_registry(repo, package, version) do
version_str = to_string(version)

%{
version: version_str,
advisories: Server.advisories(repo, package, version_str) || [],
retired: Server.retired(repo, package, version_str)
}
end

defp add_advisory(reasons, %{advisory_min_severity: threshold}, release)
when is_integer(threshold) do
threshold_atom = :mix_hex_pb_package.enum_symbol_by_value_AdvisorySeverity(threshold)
threshold_rank = severity_rank(threshold_atom)
advisories = Map.get(release, :advisories, [])

if Enum.any?(advisories, fn a -> severity_rank(Map.get(a, :severity)) >= threshold_rank end) do
[{:advisory, threshold_atom} | reasons]
else
reasons
end
end

defp add_advisory(reasons, _policy, _release), do: reasons

defp add_retirement(reasons, %{retirement_reasons: ret_reasons}, release)
when is_list(ret_reasons) and ret_reasons != [] do
case Map.get(release, :retired) do
%{reason: retired_atom} ->
atoms =
Enum.map(ret_reasons, &:mix_hex_pb_package.enum_symbol_by_value_RetirementReason/1)

if retired_atom in atoms, do: [{:retirement, retired_atom} | reasons], else: reasons

_ ->
reasons
end
end

defp add_retirement(reasons, _policy, _release), do: reasons

defp severity_rank(severity) do
Enum.find_index(@severity_order, &(&1 == severity)) || 0
end
end
Loading
Loading