Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9529fb2
Vendor hex_core with Policy resource support
ericmj May 20, 2026
927389d
Add policy configuration plumbing
ericmj May 21, 2026
a789752
Add Hex.Policy.Loader with per-policy cache
ericmj May 21, 2026
2d777d6
Add per-policy classify and strictest-wins cooldown
ericmj May 21, 2026
b74ba39
Wire policies into resolution
ericmj May 21, 2026
96d11c9
Add policy diagnostics, pre-flight check, and summary
ericmj May 21, 2026
53e1888
Add mix hex.policy task
ericmj May 21, 2026
c14b1e0
Address Plan 3 closing-review feedback
ericmj May 21, 2026
54495a3
Expand CLI moduledocs for cooldown and policy
ericmj May 21, 2026
f15f54c
Implement AND composition of policy config sources
ericmj May 22, 2026
a6d6242
Drop dead etag code in Hex.Policy.Loader
ericmj May 22, 2026
d07832f
Measure cache staleness by file mtime not policy published_at
ericmj May 22, 2026
1189ed2
Validate package arg in mix hex.policy why
ericmj May 22, 2026
0d00f63
Reject the global hexpm repo as a policy source
ericmj May 22, 2026
9402218
Re-vendor hex_core with Policy.published_at removed
ericmj May 22, 2026
2a47163
Drop published_at from policy test fixtures
ericmj May 22, 2026
d9242eb
Remove policy pre-flight check
ericmj May 22, 2026
c334010
Fetch policies through Hex.Registry.Server
ericmj May 22, 2026
251b0f9
Keep advisory severity and retirement reason in atom space in Hex.Pol…
ericmj May 22, 2026
9d6738c
Fold cooldown setup into Hex.Cooldown
ericmj May 22, 2026
41dc83c
Route policy diagnostics through Hex.Utils label helpers
ericmj May 22, 2026
88fd56a
Promote active policy lookup into Hex.Policy.active/0
ericmj May 22, 2026
2d91058
Route mix hex.policy labels through Hex.Utils and print_table
ericmj May 22, 2026
26a4f85
Read policy refs through Hex.State
ericmj May 22, 2026
6e7e434
Read repo organization from the config map in Hex.Repo.get_policy/3
ericmj May 22, 2026
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
120 changes: 118 additions & 2 deletions lib/hex/cooldown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,130 @@ 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 and stores the resolution-time cooldown state derived from the
local config and the active policy set.

Sets these `Hex.State` keys (consumed by the registry layer and the
remote converger summary):

* `:cooldown_cutoff` — built from the strictest duration across
`:cooldown` and every policy's cooldown.
* `:cooldown_bypass_packages` — packages that bypass cooldown,
either because the lock survived requirement checking or because
the locked version is known-unsafe.
* `:cooldown_locked_versions` — the version each locked package is
pinned to; the cooldown filter treats this version as exempt so
re-resolution can still fall back to it when nothing newer is
eligible.
"""
@spec setup(map(), [map()]) :: :ok
def setup(old_lock, locked) do
local = Hex.State.fetch!(:cooldown)
policies = Map.values(Hex.State.fetch!(:policies))

{_source, effective} =
strictest([
{:local, local}
| for(p <- policies, do: {{p.repository, p.name}, Map.get(p, :cooldown)})
])

cutoff = build_cutoff(effective)
Hex.State.put(:cooldown_cutoff, cutoff)
Hex.State.put(:cooldown_bypass_packages, build_bypass(old_lock, locked, cutoff))
Hex.State.put(:cooldown_locked_versions, build_locked_versions(old_lock))
:ok
end

defp build_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

defp build_bypass(_old_lock, _locked, :disabled), do: MapSet.new()

defp build_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
Hex.Registry.Server.retired(repo, name, version) != nil or
Hex.Registry.Server.advisories(repo, name, version) not in [nil, []]
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
Loading
Loading