diff --git a/lib/hex/cooldown.ex b/lib/hex/cooldown.ex index 42bed05e..9c935259 100644 --- a/lib/hex/cooldown.ex +++ b/lib/hex/cooldown.ex @@ -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 diff --git a/lib/hex/policy.ex b/lib/hex/policy.ex new file mode 100644 index 00000000..6b54f72a --- /dev/null +++ b/lib/hex/policy.ex @@ -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 diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex new file mode 100644 index 00000000..c916f24d --- /dev/null +++ b/lib/hex/policy/diagnostics.ex @@ -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 diff --git a/lib/hex/policy/filter.ex b/lib/hex/policy/filter.ex new file mode 100644 index 00000000..04f1487d --- /dev/null +++ b/lib/hex/policy/filter.ex @@ -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 diff --git a/lib/hex/policy/sources.ex b/lib/hex/policy/sources.ex new file mode 100644 index 00000000..87aca947 --- /dev/null +++ b/lib/hex/policy/sources.ex @@ -0,0 +1,123 @@ +defmodule Hex.Policy.Sources do + @moduledoc false + + @type ref :: {repo :: String.t(), name :: String.t()} + + @doc """ + Reads policy refs from all three sources independently and unions them, + deduplicated. Order: env (`HEX_POLICY`), then project (`mix.exs`), + then global (`~/.hex/hex.config`). Dedup preserves first-seen order. + + Returns `{:ok, refs}` if every source parses cleanly, otherwise `:error`. + Each source's parsing is owned by `Hex.State`, which short-circuits to + its default (`[]`) on a malformed value; this function reports `:error` + only when a source's `Hex.State` lookup raises. + """ + @spec load_all() :: {:ok, [ref()]} | :error + def load_all() do + refs = + Hex.State.fetch!(:policy_env) ++ + Hex.State.fetch!(:policy_project) ++ + Hex.State.fetch!(:policy_global) + + {:ok, dedup(refs)} + rescue + _ -> :error + end + + @doc """ + Parses a `policy` configuration value into a list of `{repo, name}` refs. + + Accepts: + * a single keyword list `[repo: "myorg", name: "strict-prod"]` + * a list of keyword lists `[[repo: ..., name: ...], ...]` + * a comma-separated string `"myorg/p,acme/b"` (env-var form) + * `nil` or `""` (no policies) + + Returns `{:ok, refs}` or `:error`. The bare `"hexpm"` repo is + rejected because the global hexpm has no organization-scoped + policies; policies live under `hexpm:` (or any non-`hexpm` + repo for self-hosted setups). + """ + @spec parse_config(term()) :: {:ok, [ref()]} | :error + def parse_config(nil), do: {:ok, []} + def parse_config(""), do: {:ok, []} + def parse_config([]), do: {:ok, []} + + def parse_config(string) when is_binary(string) do + string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.reduce_while({:ok, []}, fn entry, {:ok, acc} -> + case String.split(entry, "/") do + [repo, name] + when byte_size(repo) > 0 and byte_size(name) > 0 and repo != "hexpm" -> + {:cont, {:ok, [{repo, name} | acc]}} + + _ -> + {:halt, :error} + end + end) + |> case do + {:ok, refs} -> {:ok, Enum.reverse(refs)} + :error -> :error + end + end + + # Single keyword list — must have BOTH :repo and :name + def parse_config([{key, _} | _] = kw) when is_atom(key) do + case to_ref(kw) do + {:ok, ref} -> {:ok, [ref]} + :error -> :error + end + end + + # List of keyword lists + def parse_config(list) when is_list(list) do + Enum.reduce_while(list, {:ok, []}, fn entry, {:ok, acc} -> + case to_ref(entry) do + {:ok, ref} -> {:cont, {:ok, [ref | acc]}} + :error -> {:halt, :error} + end + end) + |> case do + {:ok, refs} -> {:ok, Enum.reverse(refs)} + :error -> :error + end + end + + def parse_config(_), do: :error + + defp to_ref(kw) when is_list(kw) do + case {Keyword.get(kw, :repo), Keyword.get(kw, :name)} do + {"hexpm", _name} -> + :error + + {repo, name} when is_binary(repo) and is_binary(name) and repo != "" and name != "" -> + {:ok, {repo, name}} + + _ -> + :error + end + end + + defp to_ref(_), do: :error + + @doc """ + Deduplicates a list of `{repo, name}` refs, preserving first-seen order. + """ + @spec dedup([ref()]) :: [ref()] + def dedup(refs) do + {result, _seen} = + Enum.reduce(refs, {[], MapSet.new()}, fn ref, {acc, seen} -> + if MapSet.member?(seen, ref) do + {acc, seen} + else + {[ref | acc], MapSet.put(seen, ref)} + end + end) + + Enum.reverse(result) + end +end diff --git a/lib/hex/registry/policy.ex b/lib/hex/registry/policy.ex new file mode 100644 index 00000000..1440cdb1 --- /dev/null +++ b/lib/hex/registry/policy.ex @@ -0,0 +1,57 @@ +defmodule Hex.Registry.Policy do + @moduledoc false + + @behaviour Hex.Solver.Registry + + alias Hex.Registry.Cooldown + alias Hex.Policy.Filter + + @impl true + defdelegate prefetch(packages), to: Cooldown + + @impl true + defdelegate dependencies(repo, package, version), to: Cooldown + + @impl true + def versions(repo, package) do + case Cooldown.versions(repo, package) do + {:ok, versions} -> + policies = Map.values(Hex.State.fetch!(:policies)) + + if policies == [] do + {:ok, versions} + else + {:ok, filter(versions, repo, package, policies)} + end + + :error -> + :error + end + end + + defp filter(versions, repo, package, policies) do + Enum.filter(versions, fn version -> + release = Filter.release_from_registry(repo, package, version) + + case Filter.classify_set(policies, release) do + :allowed -> + true + + {:blocked, blockers} -> + record_block(repo, package, version, blockers) + false + end + end) + end + + defp record_block(repo, package, version, blockers) do + entry = %{ + repo: repo || "hexpm", + package: package, + version: to_string(version), + blockers: blockers + } + + Hex.State.update!(:policy_filtered_versions, &[entry | &1]) + end +end diff --git a/lib/hex/registry/server.ex b/lib/hex/registry/server.ex index 93e1eb8b..17663214 100644 --- a/lib/hex/registry/server.ex +++ b/lib/hex/registry/server.ex @@ -34,6 +34,17 @@ defmodule Hex.Registry.Server do end end + def prefetch_policies(refs) do + case GenServer.call(@name, {:prefetch_policies, refs}, @timeout) do + :ok -> :ok + {:error, message} -> Mix.raise(message) + end + end + + def policy(repo, name) do + GenServer.call(@name, {:policy, repo, name}, @timeout) + end + def versions(repo, package) do GenServer.call(@name, {:versions, repo, package}, @timeout) end @@ -81,7 +92,10 @@ defmodule Hex.Registry.Server do pending: MapSet.new(), fetched: MapSet.new(), waiting: %{}, - pending_fun: nil + pending_fun: nil, + pending_policies: MapSet.new(), + fetched_policies: MapSet.new(), + waiting_policies: %{} } end @@ -156,6 +170,32 @@ defmodule Hex.Registry.Server do end end + def handle_call({:prefetch_policies, refs}, _from, state) do + refs = + refs + |> Enum.map(fn {repo, name} -> {repo || "hexpm", name} end) + |> Enum.uniq() + |> Enum.reject(&(&1 in state.fetched_policies)) + |> Enum.reject(&(&1 in state.pending_policies)) + + purge_repo_from_cache(refs, state) + + if Hex.State.fetch!(:offline) do + prefetch_policies_offline(refs, state) + else + prefetch_policies_online(refs, state) + end + end + + def handle_call({:policy, repo, name}, from, state) do + maybe_wait_policy({repo, name}, from, state, fn -> + case lookup(state.ets, {:policy, repo || "hexpm", name}) do + nil -> :error + decoded -> {:ok, decoded} + end + end) + end + def handle_call({:versions, repo, package}, from, state) do maybe_wait({repo, package}, from, state, fn -> case lookup(state.ets, {:versions, repo || "hexpm", package}) do @@ -258,6 +298,29 @@ defmodule Hex.Registry.Server do {:noreply, state} end + def handle_info({:get_policy, repo, name, result}, state) do + repo = repo || "hexpm" + ref = {repo, name} + pending = MapSet.delete(state.pending_policies, ref) + fetched = MapSet.put(state.fetched_policies, ref) + {replys, waiting} = Map.pop(state.waiting_policies, ref, []) + + write_policy_result(result, repo, name, state) + + Enum.each(replys, fn {from, fun} -> + GenServer.reply(from, fun.()) + end) + + state = %{ + state + | pending_policies: pending, + waiting_policies: waiting, + fetched_policies: fetched + } + + {:noreply, state} + end + defp open_ets(path) do case :ets.file2tab(path, verify: true) do {:ok, tid} -> @@ -342,6 +405,8 @@ defmodule Hex.Registry.Server do {{{:registry_etag, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:timestamp, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {{{:timestamp, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, + {{{:policy, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, + {{{:policy_etag, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]}, {:_, [], [false]} ] end @@ -386,6 +451,42 @@ defmodule Hex.Registry.Server do end end + defp prefetch_policies_online(refs, state) do + Enum.each(refs, fn {repo, name} -> + etag = policy_etag(repo, name, state) + + Hex.Parallel.run(:hex_fetcher, {:policy, repo, name}, [await: false], fn -> + {:get_policy, repo, name, Hex.Repo.get_policy(repo, name, etag)} + end) + end) + + pending = MapSet.union(MapSet.new(refs), state.pending_policies) + state = %{state | pending_policies: pending} + {:reply, :ok, state} + end + + defp prefetch_policies_offline(refs, state) do + missing = + Enum.find(refs, fn {repo, name} -> + unless lookup(state.ets, {:policy, repo, name}) do + {repo, name} + end + end) + + if missing do + {repo, name} = missing + + message = + "Hex is running in offline mode and policy " <> + "#{repo}/#{name} is not cached locally" + + {:reply, {:error, message}, state} + else + fetched = MapSet.union(MapSet.new(refs), state.fetched_policies) + {:reply, :ok, %{state | fetched_policies: fetched}} + end + end + defp write_result({:ok, {code, headers, %{releases: releases} = result}}, repo, package, %{ ets: tid }) @@ -447,6 +548,52 @@ defmodule Hex.Registry.Server do end end + defp write_policy_result({:ok, {code, headers, decoded}}, repo, name, %{ets: tid}) + when code in 200..299 and is_map(decoded) do + :ets.insert(tid, {{:policy, repo, name}, decoded}) + + if etag = headers[~c"etag"] do + :ets.insert(tid, {{:policy_etag, repo, name}, List.to_string(etag)}) + end + end + + defp write_policy_result({:ok, {304, _, _}}, _repo, _name, _state) do + :ok + end + + defp write_policy_result(other, repo, name, %{ets: tid}) do + cached? = !!:ets.lookup(tid, {:policy, repo, name}) + print_policy_error(other, repo, name, cached?) + + unless cached? do + raise "Stopping due to errors" + end + end + + defp print_policy_error(result, repo, name, cached?) do + cached_message = if cached?, do: " (using cache instead)" + + Hex.Shell.error("Failed to fetch policy #{repo}/#{name} from registry#{cached_message}") + + missing? = missing_status?(result) + unauthorized? = unauthorized_status?(result) + + if missing? or unauthorized? do + Hex.Shell.error( + "This could be because the policy does not exist, it was spelled " <> + "incorrectly or you don't have permissions to it" + ) + + if unauthorized? and not Hex.OAuth.has_tokens?() do + Hex.Shell.error("No authenticated user found. Run `mix hex.user auth` to authenticate") + end + end + + if not (missing? or unauthorized?) or Mix.debug?() do + Hex.Utils.print_error_result(result) + end + end + defp print_error(result, repo, package, cached?) do cached_message = if cached?, do: " (using cache instead)" @@ -522,6 +669,24 @@ defmodule Hex.Registry.Server do end end + defp maybe_wait_policy({repo, name}, from, state, fun) do + repo = repo || "hexpm" + + cond do + {repo, name} in state.fetched_policies -> + {:reply, fun.(), state} + + {repo, name} in state.pending_policies -> + tuple = {from, fun} + waiting = Map.update(state.waiting_policies, {repo, name}, [tuple], &[tuple | &1]) + state = %{state | waiting_policies: waiting} + {:noreply, state} + + true -> + Mix.raise("Policy #{repo}/#{name} not prefetched, please report this issue") + end + end + defp wait_pending(state, fun) do if MapSet.size(state.pending) == 0 do state = fun.(state) @@ -546,6 +711,13 @@ defmodule Hex.Registry.Server do end end + defp policy_etag(repo, name, %{ets: tid}) do + case :ets.lookup(tid, {:policy_etag, repo, name}) do + [{_, etag}] -> etag + [] -> nil + end + end + defp path do Path.join(Hex.State.fetch!(:cache_home), @filename) end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 258fbc46..67d8d4b2 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -41,6 +41,14 @@ defmodule Hex.RemoteConverger do Registry.open() + case Hex.Policy.load_all() do + {:ok, policies} -> + Hex.State.put(:policies, policies) + + {:error, reason} -> + Mix.raise(Hex.Policy.Diagnostics.format_load_error(reason)) + end + # We cannot use given lock here, because all deps that are being # converged have been removed from the lock by Mix # We need the old lock to get the children of Hex packages @@ -77,7 +85,9 @@ 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) + Hex.State.put(:policy_filtered_versions, []) + Hex.State.put(:cooldown_filtered_versions, []) + Hex.Cooldown.setup(old_lock, locked) locked = Enum.map(locked, &request_to_locked/1) overridden = Enum.map(overridden, &Atom.to_string/1) verify_otp_app_names(dependencies) @@ -88,7 +98,7 @@ defmodule Hex.RemoteConverger do solution = try do Hex.Solver.run( - Hex.Registry.Cooldown, + Hex.Registry.Policy, dependencies, locked, overridden, @@ -106,72 +116,22 @@ defmodule Hex.RemoteConverger do {:ok, resolved} -> resolved = normalize_resolved(resolved) print_cooldown_summary() + print_policy_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 + if note = Hex.Policy.Diagnostics.failure_note(Hex.State.fetch!(:policy_filtered_versions)) do + Hex.Shell.info("\n" <> note) + 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)]} + print_cooldown_summary() + print_policy_summary() + Mix.raise("Hex dependency resolution failed") 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) @@ -890,4 +850,17 @@ defmodule Hex.RemoteConverger do defp repo_uses_user_oauth?(repo_config) do Map.get(repo_config, :trusted, true) && !repo_config.auth_key end + + defp print_policy_summary() do + policies = Map.values(Hex.State.fetch!(:policies)) + + if policies != [] do + filtered = Hex.State.fetch!(:policy_filtered_versions) + local_cooldown = Hex.State.fetch!(:cooldown) + + if summary = Hex.Policy.Diagnostics.resolution_summary(policies, filtered, local_cooldown) do + Hex.Shell.info(summary) + end + end + end end diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index 76326ae1..0d424ef0 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -78,6 +78,7 @@ defmodule Hex.Repo do |> Map.put(:oauth_exchange, oauth_exchange) |> Map.put(:oauth_exchange_url, oauth_exchange_url) |> Map.put(:trusted, Map.has_key?(repo, :auth_key) or source.trusted) + |> Map.put(:organization, name) end def hexpm_repo() do @@ -197,7 +198,7 @@ defmodule Hex.Repo do defp clean_repo(repo, default) do repo |> clean_expired_oauth_token() - |> Map.delete(:trusted) + |> Map.drop([:trusted, :organization]) |> Enum.reject(fn {key, value} -> value in [nil, Map.get(default, key)] end) |> Map.new() end @@ -228,6 +229,32 @@ defmodule Hex.Repo do :mix_hex_repo.get_package(config, package) end + @doc """ + Fetches a policy resource from the given repo. + + Requires the repo to be an organization-scoped config (`hexpm:myorg` + on hex.pm). The underlying `:mix_hex_repo.get_policy/2` returns + `{:error, :missing_repo_organization}` if `repo_organization` is unset. + """ + def get_policy(repo, name, etag) do + repo_config = get_repo(repo) + config = build_hex_core_config(repo_config, repo, etag) + config = put_repo_organization(config, repo_config) + :mix_hex_repo.get_policy(config, name) + end + + defp put_repo_organization(config, repo_config) do + case Map.get(repo_config, :organization) do + nil -> + config + + organization -> + suffix = "/repos/#{organization}" + base_url = String.replace_suffix(repo_config.url, suffix, "") + %{config | repo_url: base_url, repo_organization: organization} + end + end + def get_docs(repo, package, version) do repo_config = get_repo(repo) config = build_hex_core_config(repo_config, repo) diff --git a/lib/hex/state.ex b/lib/hex/state.ex index e273b47a..94a16866 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -142,6 +142,24 @@ defmodule Hex.State do default: [], skip_env_if_empty: true, fun: {Hex.Cooldown, :parse_exclude_repos} + }, + policy_project: %{ + config: [:policy], + config_scope: :project, + default: [], + fun: {Hex.Policy.Sources, :parse_config} + }, + policy_env: %{ + env: ["HEX_POLICY"], + skip_env_if_empty: true, + default: [], + fun: {Hex.Policy.Sources, :parse_config} + }, + policy_global: %{ + config: [:policy], + config_scope: :global, + default: [], + fun: {Hex.Policy.Sources, :parse_config} } } @@ -175,7 +193,9 @@ defmodule Hex.State do pbkdf2_iters: {:computed, @pbkdf2_iters}, repos: {:computed, Hex.Config.read_repos(global_config)}, ssl_version: {:computed, ssl_version()}, - shell_process: {:computed, nil} + shell_process: {:computed, nil}, + policies: {:computed, %{}}, + policy_filtered_versions: {:computed, []} }) end @@ -249,8 +269,8 @@ defmodule Hex.State do result = load_env(spec[:env], env, spec[:skip_env_if_empty]) || - load_project_config(project_config, spec[:config]) || - load_global_config(global_config, spec[:config]) + maybe_load_project(project_config, spec) || + maybe_load_global(global_config, spec) {module, func} = spec[:fun] || {__MODULE__, :ok_wrap} @@ -293,6 +313,12 @@ defmodule Hex.State do |> Path.join("hex.config") end + defp maybe_load_project(_config, %{config_scope: :global}), do: nil + defp maybe_load_project(config, spec), do: load_project_config(config, spec[:config]) + + defp maybe_load_global(_config, %{config_scope: :project}), do: nil + defp maybe_load_global(config, spec), do: load_global_config(config, spec[:config]) + defp load_env(keys, env, skip_if_empty) do Enum.find_value(keys || [], fn key -> case Map.fetch(env, key) do diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index 164a2432..f43f2ed8 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -68,13 +68,29 @@ defmodule Mix.Tasks.Hex.Config do 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) + `HEX_COOLDOWN` (Default: `"0d"` — no cooldown). See + https://hex.pm/docs/dependency-policies for the full guide. * `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: `[]`) + comma-separated list (Default: `[]`). See + https://hex.pm/docs/dependency-policies for the full guide. + * `policy` - One or more policy references this Hex client should + honor at resolution time. The simplest way to set it is via + `mix hex.config policy /` (multiple policies are + comma-separated). The `mix.exs` `:hex` block accepts a richer + form: `policy: [repo: "", name: ""]` or a list of + such keyword lists. All three sources (`mix.exs`, `HEX_POLICY`, + and `~/.hex/hex.config`) are read independently and unioned — + every policy contributed by any source must pass for a release + to be allowed (AND composition; no source can subtract another). + See `mix hex.policy show` for a summary of the active set, or + https://hex.pm/docs/dependency-policies for the full guide. + * `HEX_POLICY` - Comma-separated `org/name` pairs that contribute + additional policies to the active set for this invocation. Example: + `HEX_POLICY=myorg/strict-prod,acme/baseline`. Hex responds to these additional environment variables: @@ -177,24 +193,43 @@ defmodule Mix.Tasks.Hex.Config do Enum.each(valid_read_keys(), fn {config, _internal} -> read(config, true) end) + + read(:policy, true) end defp read(key, verbose \\ false) + defp read(:policy, verbose), do: print_policy(verbose) + defp read(key, verbose) when is_binary(key) do - key = String.to_atom(key) + case String.to_atom(key) do + :policy -> + print_policy(verbose) - case Keyword.fetch(valid_read_keys(), key) do - {:ok, internal} -> - fetch_current_value_and_print(internal, key, verbose) + atom -> + case Keyword.fetch(valid_read_keys(), atom) do + {:ok, internal} -> + fetch_current_value_and_print(internal, atom, verbose) - _error -> - Mix.raise("The key #{key} is not valid") + _error -> + Mix.raise("The key #{key} is not valid") + end end end defp read(key, verbose) when is_atom(key), do: read(to_string(key), verbose) + defp print_policy(verbose) do + case Hex.Policy.Sources.load_all() do + {:ok, refs} -> + rendered = Enum.map_join(refs, ",", fn {repo, name} -> "#{repo}/#{name}" end) + print_value(:policy, rendered, verbose, "(composed from all sources)") + + :error -> + Mix.raise("Invalid policy configuration in one or more sources") + end + end + defp fetch_current_value_and_print(internal, key, verbose) do case Map.fetch(Hex.State.get_all(), internal) do {:ok, {{:env, env_var}, value}} -> @@ -222,18 +257,33 @@ defmodule Mix.Tasks.Hex.Config do defp delete(key) do key = String.to_atom(key) - if Keyword.has_key?(valid_write_keys(), key) do - Hex.Config.remove([key]) + cond do + key == :policy -> + Hex.Config.remove([:policy]) + + Keyword.has_key?(valid_write_keys(), key) -> + Hex.Config.remove([key]) + + true -> + :ok end end defp set(key, value) do key = String.to_atom(key) - if Keyword.has_key?(valid_write_keys(), key) do - Hex.Config.update([{key, value}]) - else - Mix.raise("Invalid key #{key}") + cond do + key == :policy -> + case Hex.Policy.Sources.parse_config(value) do + {:ok, _refs} -> Hex.Config.update(policy: value) + :error -> Mix.raise("Invalid policy value: #{inspect(value)}") + end + + Keyword.has_key?(valid_write_keys(), key) -> + Hex.Config.update([{key, value}]) + + true -> + Mix.raise("Invalid key #{key}") end end diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex new file mode 100644 index 00000000..05c06589 --- /dev/null +++ b/lib/mix/tasks/hex.policy.ex @@ -0,0 +1,222 @@ +defmodule Mix.Tasks.Hex.Policy do + use Mix.Task + + alias Hex.Cooldown + alias Hex.Policy.{Diagnostics, Filter} + + @shortdoc "Inspects active Hex dependency policies" + + @moduledoc """ + Shows the active Hex policy set and explains why specific versions + are blocked. + + $ mix hex.policy [show] + $ mix hex.policy why PACKAGE + + ## Commands + + * `show` (default) — Summarize the active policy set: per-policy + visibility, source, cooldown, advisory + retirement rule state, + plus the effective cooldown. + * `why PACKAGE` — Walk every version of the named package in the + registry, classify each against every active policy, and print a + per-version table of status and responsible policies. + + ## Opting in + + Three configuration sources contribute to the active policy set. + All three are read independently and unioned — every policy + contributed by any source must pass for a release to be allowed + (AND composition; no source can subtract from another): + + * `mix.exs` `:hex` block: + + defp project() do + [hex: [policy: [repo: "myorg", name: "strict-prod"]]] + end + + * `HEX_POLICY` env var (comma-separated `org/name` pairs): + + $ HEX_POLICY=myorg/strict-prod,acme/baseline mix deps.get + + * `mix hex.config`: + + $ mix hex.config policy myorg/strict-prod + + See https://hex.pm/docs/dependency-policies for the full guide. + """ + + @behaviour Hex.Mix.TaskDescription + + @impl true + def run(args) do + Hex.start() + + case args do + [] -> show() + ["show"] -> show() + ["why", package] -> why(package) + ["why"] -> Mix.raise("Usage: mix hex.policy why PACKAGE") + _ -> Mix.raise(@moduledoc) + end + end + + @impl true + def tasks() do + [ + {"", "Shows the active policy set"}, + {"show", "Shows the active policy set"}, + {"why PACKAGE", "Shows which versions of PACKAGE are blocked by active policies"} + ] + end + + defp show() do + case Hex.Policy.active() do + {:ok, policies_map} when map_size(policies_map) > 0 -> + render_show(policies_map) + + {:ok, _} -> + Hex.Shell.info("No active policies configured.") + + {:error, reason} -> + Mix.raise(Diagnostics.format_load_error(reason)) + end + end + + defp render_show(policies_map) do + policies = Map.values(policies_map) + Hex.Shell.info("Active policies (#{length(policies)}):\n") + + Enum.each(policies, fn p -> + Hex.Shell.info(" #{p.repository}/#{p.name} [#{visibility_label(p.visibility)}]") + + cooldown = Map.get(p, :cooldown) || "(none)" + Hex.Shell.info(" Cooldown: #{cooldown}") + + Hex.Shell.info(" Advisory rule: #{advisory_label(Map.get(p, :advisory_min_severity))}") + + Hex.Shell.info( + " Retirement rule: #{retirement_label(Map.get(p, :retirement_reasons) || [])}" + ) + + Hex.Shell.info("") + end) + + local = Hex.State.fetch!(:cooldown) + + case Cooldown.strictest([ + {:local, local} + | Enum.map(policies, fn p -> {{p.repository, p.name}, Map.get(p, :cooldown)} end) + ]) do + {_source, "0d"} -> + :ok + + {source, duration} -> + source_str = + case source do + :local -> "local" + {repo, name} -> "#{repo}/#{name}" + end + + Hex.Shell.info("Effective cooldown: #{duration} (from #{source_str})") + end + end + + defp why(arg) do + {repo, package} = parse_package_arg(arg) + + case Hex.Policy.active() do + {:ok, policies_map} when map_size(policies_map) > 0 -> + Hex.Registry.Server.open() + Hex.Registry.Server.prefetch([{repo, package}]) + + case Hex.Registry.Server.versions(repo, package) do + {:ok, versions} -> + render_why(repo, package, versions, Map.values(policies_map)) + + :error -> + Mix.raise("No package with name #{package} in registry") + end + + {:ok, _} -> + Hex.Shell.info("No active policies configured.") + + {:error, reason} -> + Mix.raise(Diagnostics.format_load_error(reason)) + end + end + + defp parse_package_arg(arg) do + case String.split(arg, "/", parts: 2) do + [package] when byte_size(package) > 0 -> + {"hexpm", package} + + [repo, package] when byte_size(repo) > 0 and byte_size(package) > 0 -> + {repo, package} + + _ -> + Mix.raise("Invalid package argument: #{inspect(arg)}; expected PACKAGE or REPO/PACKAGE") + end + end + + defp render_why(repo, package, versions, policies) do + Hex.Shell.info("Versions of #{inspect(package)} (#{length(versions)}):") + Hex.Shell.info("") + + rows = + Enum.map(versions, fn v -> + version = to_string(v) + release = Filter.release_from_registry(repo, package, version) + + case Filter.classify_set(policies, release) do + :allowed -> + [version, "ALLOWED", ""] + + {:blocked, blockers} -> + blocker_text = + blockers + |> Enum.map(fn b -> + "#{b.policy.repository}/#{b.policy.name} (#{format_reason(b.reason)})" + end) + |> Enum.uniq() + |> Enum.join(", ") + + [version, "BLOCKED", blocker_text] + end + end) + + Mix.Tasks.Hex.print_table(["Version", "Status", "Blocked by"], rows) + end + + defp format_reason({:advisory, sev}), + do: "advisory ≥ #{Hex.Utils.advisory_severity(sev)}" + + defp format_reason({:retirement, r}), + do: "retirement: #{Hex.Utils.package_retirement_reason(r)}" + + defp visibility_label(:VISIBILITY_PUBLIC), do: "public" + defp visibility_label(:VISIBILITY_PRIVATE), do: "private" + defp visibility_label(other), do: to_string(other) + + defp advisory_label(nil), do: "(disabled)" + defp advisory_label(0), do: "block any advisory" + + defp advisory_label(int) when is_integer(int) do + atom = :mix_hex_pb_package.enum_symbol_by_value_AdvisorySeverity(int) + "block ≥ #{Hex.Utils.advisory_severity(atom)}" + end + + defp retirement_label([]), do: "(disabled)" + defp retirement_label(nil), do: "(disabled)" + + defp retirement_label(reasons) when is_list(reasons) do + reasons + |> Enum.map(fn r -> + r + |> :mix_hex_pb_package.enum_symbol_by_value_RetirementReason() + |> Hex.Utils.package_retirement_reason() + |> String.upcase() + end) + |> Enum.join(", ") + end +end diff --git a/scripts/vendor_hex_core.sh b/scripts/vendor_hex_core.sh index 43fddf8c..044d0b24 100755 --- a/scripts/vendor_hex_core.sh +++ b/scripts/vendor_hex_core.sh @@ -33,6 +33,7 @@ filenames="hex_api_auth.erl \ hex_licenses.erl \ hex_pb_names.erl \ hex_pb_package.erl \ + hex_pb_policy.erl \ hex_pb_signed.erl \ hex_pb_versions.erl \ hex_registry.erl \ @@ -49,6 +50,7 @@ search_to_replace="hex_core: \ hex_licenses \ hex_pb_names \ hex_pb_package \ + hex_pb_policy \ hex_pb_signed \ hex_pb_versions \ hex_registry \ diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 136b134d..897bc274 100644 --- a/src/mix_hex_advisory.erl +++ b/src/mix_hex_advisory.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Display-time deduplication of security advisories. diff --git a/src/mix_hex_api.erl b/src/mix_hex_api.erl index 88a2360f..38cb8890 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index 7488b553..f4c544e1 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Authentication. diff --git a/src/mix_hex_api_key.erl b/src/mix_hex_api_key.erl index 4304f95e..ce0fa56e 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Keys. diff --git a/src/mix_hex_api_oauth.erl b/src/mix_hex_api_oauth.erl index b8ca65ed..ba0921d0 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - OAuth. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index 0c4fe54e..597c021a 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Organizations. diff --git a/src/mix_hex_api_organization_member.erl b/src/mix_hex_api_organization_member.erl index 3121eccd..b7765769 100644 --- a/src/mix_hex_api_organization_member.erl +++ b/src/mix_hex_api_organization_member.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Organization Members. diff --git a/src/mix_hex_api_package.erl b/src/mix_hex_api_package.erl index 2ce96fc2..cf64a4a0 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Packages. diff --git a/src/mix_hex_api_package_owner.erl b/src/mix_hex_api_package_owner.erl index 79f5500d..d09b382a 100644 --- a/src/mix_hex_api_package_owner.erl +++ b/src/mix_hex_api_package_owner.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Package Owners. diff --git a/src/mix_hex_api_release.erl b/src/mix_hex_api_release.erl index d8c21396..b354eb05 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Releases. diff --git a/src/mix_hex_api_short_url.erl b/src/mix_hex_api_short_url.erl index be4a71ac..e7fd9778 100644 --- a/src/mix_hex_api_short_url.erl +++ b/src/mix_hex_api_short_url.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Short URLs. diff --git a/src/mix_hex_api_user.erl b/src/mix_hex_api_user.erl index 2aa9558e..99d5e128 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex HTTP API - Users. diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index c5277517..0bd68773 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% `hex_core' entrypoint module. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index 90c3d7de..5a46913c 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually -define(HEX_CORE_VERSION, "0.17.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index 7bac331c..aaf36068 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% This file is a copy of erl_tar.erl from OTP with the following modifications: %% 1. Module renamed from erl_tar to mix_hex_erl_tar diff --git a/src/mix_hex_erl_tar.hrl b/src/mix_hex_erl_tar.hrl index 1ac7835c..b1f16435 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% This file is a copy of erl_tar.hrl from OTP with the following modifications: %% 1. Added chunk_size field to #read_opts{} for streaming extraction to disk diff --git a/src/mix_hex_http.erl b/src/mix_hex_http.erl index 4f0b0dbd..5041a8b8 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index a04eb8bc..a5a65410 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% httpc-based implementation of {@link mix_hex_http} contract. diff --git a/src/mix_hex_licenses.erl b/src/mix_hex_licenses.erl index 2ba8dfe5..15e747b8 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index 041ded9b..4a7d8a2e 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_package.erl b/src/mix_hex_pb_package.erl index baa37340..eafe232a 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_policy.erl b/src/mix_hex_pb_policy.erl new file mode 100644 index 00000000..d580821d --- /dev/null +++ b/src/mix_hex_pb_policy.erl @@ -0,0 +1,777 @@ +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually + +%% -*- coding: utf-8 -*- +%% % this file is @generated +%% @private +%% Automatically generated, do not edit +%% Generated by gpb_compile version 4.21.1 +%% Version source: file +-module(mix_hex_pb_policy). + +-export([encode_msg/2, encode_msg/3]). +-export([decode_msg/2, decode_msg/3]). +-export([merge_msgs/3, merge_msgs/4]). +-export([verify_msg/2, verify_msg/3]). +-export([get_msg_defs/0]). +-export([get_msg_names/0]). +-export([get_group_names/0]). +-export([get_msg_or_group_names/0]). +-export([get_enum_names/0]). +-export([find_msg_def/1, fetch_msg_def/1]). +-export([find_enum_def/1, fetch_enum_def/1]). +-export([enum_symbol_by_value/2, enum_value_by_symbol/2]). +-export([enum_symbol_by_value_Visibility/1, enum_value_by_symbol_Visibility/1]). +-export([get_service_names/0]). +-export([get_service_def/1]). +-export([get_rpc_names/1]). +-export([find_rpc_def/2, fetch_rpc_def/2]). +-export([fqbin_to_service_name/1]). +-export([service_name_to_fqbin/1]). +-export([fqbins_to_service_and_rpc_name/2]). +-export([service_and_rpc_name_to_fqbins/2]). +-export([fqbin_to_msg_name/1]). +-export([msg_name_to_fqbin/1]). +-export([fqbin_to_enum_name/1]). +-export([enum_name_to_fqbin/1]). +-export([get_package_name/0]). +-export([uses_packages/0]). +-export([source_basename/0]). +-export([get_all_source_basenames/0]). +-export([get_all_proto_names/0]). +-export([get_msg_containment/1]). +-export([get_pkg_containment/1]). +-export([get_service_containment/1]). +-export([get_rpc_containment/1]). +-export([get_enum_containment/1]). +-export([get_proto_by_msg_name_as_fqbin/1]). +-export([get_proto_by_service_name_as_fqbin/1]). +-export([get_proto_by_enum_name_as_fqbin/1]). +-export([get_protos_by_pkg_name_as_fqbin/1]). +-export([gpb_version_as_string/0, gpb_version_as_list/0]). +-export([gpb_version_source/0]). + + +%% enumerated types +-type 'Visibility'() :: 'VISIBILITY_PRIVATE' | 'VISIBILITY_PUBLIC'. +-export_type(['Visibility'/0]). + +%% message types +-type 'Policy'() :: + #{repository => unicode:chardata(), % = 1, required + name => unicode:chardata(), % = 2, required + description => unicode:chardata(), % = 3, optional + visibility => 'VISIBILITY_PRIVATE' | 'VISIBILITY_PUBLIC' | integer(), % = 4, required, enum Visibility + advisory_min_severity => non_neg_integer(), % = 5, optional, 32 bits + retirement_reasons => [non_neg_integer()], % = 6, repeated, 32 bits + cooldown => unicode:chardata() % = 7, optional + }. + +-export_type(['Policy'/0]). +-type '$msg_name'() :: 'Policy'. +-type '$msg'() :: 'Policy'(). +-export_type(['$msg_name'/0, '$msg'/0]). + +-spec encode_msg('$msg'(), '$msg_name'()) -> binary(). +encode_msg(Msg, MsgName) when is_atom(MsgName) -> encode_msg(Msg, MsgName, []). + +-spec encode_msg('$msg'(), '$msg_name'(), list()) -> binary(). +encode_msg(Msg, MsgName, Opts) -> + verify_msg(Msg, MsgName, Opts), + TrUserData = proplists:get_value(user_data, Opts), + case MsgName of 'Policy' -> encode_msg_Policy(id(Msg, TrUserData), TrUserData) end. + + +encode_msg_Policy(Msg, TrUserData) -> encode_msg_Policy(Msg, <<>>, TrUserData). + + +encode_msg_Policy(#{repository := F1, name := F2, visibility := F4} = M, Bin, TrUserData) -> + B1 = begin TrF1 = id(F1, TrUserData), e_type_string(TrF1, <>, TrUserData) end, + B2 = begin TrF2 = id(F2, TrUserData), e_type_string(TrF2, <>, TrUserData) end, + B3 = case M of + #{description := F3} -> begin TrF3 = id(F3, TrUserData), e_type_string(TrF3, <>, TrUserData) end; + _ -> B2 + end, + B4 = begin TrF4 = id(F4, TrUserData), e_enum_Visibility(TrF4, <>, TrUserData) end, + B5 = case M of + #{advisory_min_severity := F5} -> begin TrF5 = id(F5, TrUserData), e_varint(TrF5, <>, TrUserData) end; + _ -> B4 + end, + B6 = case M of + #{retirement_reasons := F6} -> + TrF6 = id(F6, TrUserData), + if TrF6 == [] -> B5; + true -> e_field_Policy_retirement_reasons(TrF6, B5, TrUserData) + end; + _ -> B5 + end, + case M of + #{cooldown := F7} -> begin TrF7 = id(F7, TrUserData), e_type_string(TrF7, <>, TrUserData) end; + _ -> B6 + end. + +e_field_Policy_retirement_reasons(Elems, Bin, TrUserData) when Elems =/= [] -> + SubBin = e_pfield_Policy_retirement_reasons(Elems, <<>>, TrUserData), + Bin2 = <>, + Bin3 = e_varint(byte_size(SubBin), Bin2), + <>; +e_field_Policy_retirement_reasons([], Bin, _TrUserData) -> Bin. + +e_pfield_Policy_retirement_reasons([Value | Rest], Bin, TrUserData) -> + Bin2 = e_varint(id(Value, TrUserData), Bin, TrUserData), + e_pfield_Policy_retirement_reasons(Rest, Bin2, TrUserData); +e_pfield_Policy_retirement_reasons([], Bin, _TrUserData) -> Bin. + +e_enum_Visibility('VISIBILITY_PRIVATE', Bin, _TrUserData) -> <>; +e_enum_Visibility('VISIBILITY_PUBLIC', Bin, _TrUserData) -> <>; +e_enum_Visibility(V, Bin, _TrUserData) -> e_varint(V, Bin). + +-compile({nowarn_unused_function,e_type_sint/3}). +e_type_sint(Value, Bin, _TrUserData) when Value >= 0 -> e_varint(Value * 2, Bin); +e_type_sint(Value, Bin, _TrUserData) -> e_varint(Value * -2 - 1, Bin). + +-compile({nowarn_unused_function,e_type_int32/3}). +e_type_int32(Value, Bin, _TrUserData) when 0 =< Value, Value =< 127 -> <>; +e_type_int32(Value, Bin, _TrUserData) -> + <> = <>, + e_varint(N, Bin). + +-compile({nowarn_unused_function,e_type_int64/3}). +e_type_int64(Value, Bin, _TrUserData) when 0 =< Value, Value =< 127 -> <>; +e_type_int64(Value, Bin, _TrUserData) -> + <> = <>, + e_varint(N, Bin). + +-compile({nowarn_unused_function,e_type_bool/3}). +e_type_bool(true, Bin, _TrUserData) -> <>; +e_type_bool(false, Bin, _TrUserData) -> <>; +e_type_bool(1, Bin, _TrUserData) -> <>; +e_type_bool(0, Bin, _TrUserData) -> <>. + +-compile({nowarn_unused_function,e_type_string/3}). +e_type_string(S, Bin, _TrUserData) -> + Utf8 = unicode:characters_to_binary(S), + Bin2 = e_varint(byte_size(Utf8), Bin), + <>. + +-compile({nowarn_unused_function,e_type_bytes/3}). +e_type_bytes(Bytes, Bin, _TrUserData) when is_binary(Bytes) -> + Bin2 = e_varint(byte_size(Bytes), Bin), + <>; +e_type_bytes(Bytes, Bin, _TrUserData) when is_list(Bytes) -> + BytesBin = iolist_to_binary(Bytes), + Bin2 = e_varint(byte_size(BytesBin), Bin), + <>. + +-compile({nowarn_unused_function,e_type_fixed32/3}). +e_type_fixed32(Value, Bin, _TrUserData) -> <>. + +-compile({nowarn_unused_function,e_type_sfixed32/3}). +e_type_sfixed32(Value, Bin, _TrUserData) -> <>. + +-compile({nowarn_unused_function,e_type_fixed64/3}). +e_type_fixed64(Value, Bin, _TrUserData) -> <>. + +-compile({nowarn_unused_function,e_type_sfixed64/3}). +e_type_sfixed64(Value, Bin, _TrUserData) -> <>. + +-compile({nowarn_unused_function,e_type_float/3}). +e_type_float(V, Bin, _) when is_number(V) -> <>; +e_type_float(infinity, Bin, _) -> <>; +e_type_float('-infinity', Bin, _) -> <>; +e_type_float(nan, Bin, _) -> <>. + +-compile({nowarn_unused_function,e_type_double/3}). +e_type_double(V, Bin, _) when is_number(V) -> <>; +e_type_double(infinity, Bin, _) -> <>; +e_type_double('-infinity', Bin, _) -> <>; +e_type_double(nan, Bin, _) -> <>. + +-compile({nowarn_unused_function,e_unknown_elems/2}). +e_unknown_elems([Elem | Rest], Bin) -> + BinR = case Elem of + {varint, FNum, N} -> + BinF = e_varint(FNum bsl 3, Bin), + e_varint(N, BinF); + {length_delimited, FNum, Data} -> + BinF = e_varint(FNum bsl 3 bor 2, Bin), + BinL = e_varint(byte_size(Data), BinF), + <>; + {group, FNum, GroupFields} -> + Bin1 = e_varint(FNum bsl 3 bor 3, Bin), + Bin2 = e_unknown_elems(GroupFields, Bin1), + e_varint(FNum bsl 3 bor 4, Bin2); + {fixed32, FNum, V} -> + BinF = e_varint(FNum bsl 3 bor 5, Bin), + <>; + {fixed64, FNum, V} -> + BinF = e_varint(FNum bsl 3 bor 1, Bin), + <> + end, + e_unknown_elems(Rest, BinR); +e_unknown_elems([], Bin) -> Bin. + +-compile({nowarn_unused_function,e_varint/3}). +e_varint(N, Bin, _TrUserData) -> e_varint(N, Bin). + +-compile({nowarn_unused_function,e_varint/2}). +e_varint(N, Bin) when N =< 127 -> <>; +e_varint(N, Bin) -> + Bin2 = <>, + e_varint(N bsr 7, Bin2). + + +decode_msg(Bin, MsgName) when is_binary(Bin) -> decode_msg(Bin, MsgName, []). + +decode_msg(Bin, MsgName, Opts) when is_binary(Bin) -> + TrUserData = proplists:get_value(user_data, Opts), + decode_msg_1_catch(Bin, MsgName, TrUserData). + +-ifdef('OTP_RELEASE'). +decode_msg_1_catch(Bin, MsgName, TrUserData) -> + try decode_msg_2_doit(MsgName, Bin, TrUserData) + catch + error:{gpb_error,_}=Reason:StackTrace -> + erlang:raise(error, Reason, StackTrace); + Class:Reason:StackTrace -> error({gpb_error,{decoding_failure, {Bin, MsgName, {Class, Reason, StackTrace}}}}) + end. +-else. +decode_msg_1_catch(Bin, MsgName, TrUserData) -> + try decode_msg_2_doit(MsgName, Bin, TrUserData) + catch + error:{gpb_error,_}=Reason -> + erlang:raise(error, Reason, + erlang:get_stacktrace()); + Class:Reason -> + StackTrace = erlang:get_stacktrace(), + error({gpb_error,{decoding_failure, {Bin, MsgName, {Class, Reason, StackTrace}}}}) + end. +-endif. + +decode_msg_2_doit('Policy', Bin, TrUserData) -> id(decode_msg_Policy(Bin, TrUserData), TrUserData). + + + +decode_msg_Policy(Bin, TrUserData) -> + dfp_read_field_def_Policy(Bin, 0, 0, 0, id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id([], TrUserData), id('$undef', TrUserData), TrUserData). + +dfp_read_field_def_Policy(<<10, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_repository(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<18, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_name(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<26, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_description(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_visibility(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<40, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_advisory_min_severity(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<50, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_pfield_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<48, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<58, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_Policy_cooldown(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_Policy(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, R1, F@_7, TrUserData) -> + S1 = #{repository => F@_1, name => F@_2, visibility => F@_4, retirement_reasons => lists_reverse(R1, TrUserData)}, + S2 = if F@_3 == '$undef' -> S1; + true -> S1#{description => F@_3} + end, + S3 = if F@_5 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_5} + end, + if F@_7 == '$undef' -> S3; + true -> S3#{cooldown => F@_7} + end; +dfp_read_field_def_Policy(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dg_read_field_def_Policy(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +dg_read_field_def_Policy(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 32 - 7 -> dg_read_field_def_Policy(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dg_read_field_def_Policy(<<0:1, X:7, Rest/binary>>, N, Acc, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + Key = X bsl N + Acc, + case Key of + 10 -> d_field_Policy_repository(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 18 -> d_field_Policy_name(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 26 -> d_field_Policy_description(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 32 -> d_field_Policy_visibility(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 40 -> d_field_Policy_advisory_min_severity(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 50 -> d_pfield_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 48 -> d_field_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 58 -> d_field_Policy_cooldown(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + _ -> + case Key band 7 of + 0 -> skip_varint_Policy(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 1 -> skip_64_Policy(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 2 -> skip_length_delimited_Policy(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 3 -> skip_group_Policy(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 5 -> skip_32_Policy(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) + end + end; +dg_read_field_def_Policy(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, R1, F@_7, TrUserData) -> + S1 = #{repository => F@_1, name => F@_2, visibility => F@_4, retirement_reasons => lists_reverse(R1, TrUserData)}, + S2 = if F@_3 == '$undef' -> S1; + true -> S1#{description => F@_3} + end, + S3 = if F@_5 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_5} + end, + if F@_7 == '$undef' -> S3; + true -> S3#{cooldown => F@_7} + end. + +d_field_Policy_repository(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_Policy_repository(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_repository(<<0:1, X:7, Rest/binary>>, N, Acc, F, _, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, + dfp_read_field_def_Policy(RestF, 0, 0, F, NewFValue, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +d_field_Policy_name(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_Policy_name(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_name(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, _, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, NewFValue, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +d_field_Policy_description(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_Policy_description(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_description(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, _, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, NewFValue, F@_4, F@_5, F@_6, F@_7, TrUserData). + +d_field_Policy_visibility(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_Policy_visibility(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_visibility(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, _, F@_5, F@_6, F@_7, TrUserData) -> + {NewFValue, RestF} = {id(d_enum_Visibility(begin <> = <<(X bsl N + Acc):32/unsigned-native>>, id(Res, TrUserData) end), TrUserData), Rest}, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, NewFValue, F@_5, F@_6, F@_7, TrUserData). + +d_field_Policy_advisory_min_severity(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_Policy_advisory_min_severity(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_advisory_min_severity(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, TrUserData) -> + {NewFValue, RestF} = {id((X bsl N + Acc) band 4294967295, TrUserData), Rest}, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, NewFValue, F@_6, F@_7, TrUserData). + +d_field_Policy_retirement_reasons(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_Policy_retirement_reasons(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_retirement_reasons(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, Prev, F@_7, TrUserData) -> + {NewFValue, RestF} = {id((X bsl N + Acc) band 4294967295, TrUserData), Rest}, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, cons(NewFValue, Prev, TrUserData), F@_7, TrUserData). + +d_pfield_Policy_retirement_reasons(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_pfield_Policy_retirement_reasons(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_pfield_Policy_retirement_reasons(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, E, F@_7, TrUserData) -> + Len = X bsl N + Acc, + <> = Rest, + NewSeq = d_packed_field_Policy_retirement_reasons(PackedBytes, 0, 0, F, E, TrUserData), + dfp_read_field_def_Policy(Rest2, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, NewSeq, F@_7, TrUserData). + +d_packed_field_Policy_retirement_reasons(<<1:1, X:7, Rest/binary>>, N, Acc, F, AccSeq, TrUserData) when N < 57 -> d_packed_field_Policy_retirement_reasons(Rest, N + 7, X bsl N + Acc, F, AccSeq, TrUserData); +d_packed_field_Policy_retirement_reasons(<<0:1, X:7, Rest/binary>>, N, Acc, F, AccSeq, TrUserData) -> + {NewFValue, RestF} = {id((X bsl N + Acc) band 4294967295, TrUserData), Rest}, + d_packed_field_Policy_retirement_reasons(RestF, 0, 0, F, [NewFValue | AccSeq], TrUserData); +d_packed_field_Policy_retirement_reasons(<<>>, 0, 0, _, AccSeq, _) -> AccSeq. + +d_field_Policy_cooldown(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_Policy_cooldown(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_Policy_cooldown(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, _, TrUserData) -> + {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, NewFValue, TrUserData). + +skip_varint_Policy(<<1:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> skip_varint_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +skip_varint_Policy(<<0:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +skip_length_delimited_Policy(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> skip_length_delimited_Policy(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +skip_length_delimited_Policy(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + Length = X bsl N + Acc, + <<_:Length/binary, Rest2/binary>> = Rest, + dfp_read_field_def_Policy(Rest2, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +skip_group_Policy(Bin, _, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> + {_, Rest} = read_group(Bin, FNum), + dfp_read_field_def_Policy(Rest, 0, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +skip_32_Policy(<<_:32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +skip_64_Policy(<<_:64, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). + +d_enum_Visibility(0) -> 'VISIBILITY_PRIVATE'; +d_enum_Visibility(1) -> 'VISIBILITY_PUBLIC'; +d_enum_Visibility(V) -> V. + +read_group(Bin, FieldNum) -> + {NumBytes, EndTagLen} = read_gr_b(Bin, 0, 0, 0, 0, FieldNum), + <> = Bin, + {Group, Rest}. + +%% Like skipping over fields, but record the total length, +%% Each field is <(FieldNum bsl 3) bor FieldType> ++ +%% Record the length because varints may be non-optimally encoded. +%% +%% Groups can be nested, but assume the same FieldNum cannot be nested +%% because group field numbers are shared with the rest of the fields +%% numbers. Thus we can search just for an group-end with the same +%% field number. +%% +%% (The only time the same group field number could occur would +%% be in a nested sub message, but then it would be inside a +%% length-delimited entry, which we skip-read by length.) +read_gr_b(<<1:1, X:7, Tl/binary>>, N, Acc, NumBytes, TagLen, FieldNum) + when N < (32-7) -> + read_gr_b(Tl, N+7, X bsl N + Acc, NumBytes, TagLen+1, FieldNum); +read_gr_b(<<0:1, X:7, Tl/binary>>, N, Acc, NumBytes, TagLen, + FieldNum) -> + Key = X bsl N + Acc, + TagLen1 = TagLen + 1, + case {Key bsr 3, Key band 7} of + {FieldNum, 4} -> % 4 = group_end + {NumBytes, TagLen1}; + {_, 0} -> % 0 = varint + read_gr_vi(Tl, 0, NumBytes + TagLen1, FieldNum); + {_, 1} -> % 1 = bits64 + <<_:64, Tl2/binary>> = Tl, + read_gr_b(Tl2, 0, 0, NumBytes + TagLen1 + 8, 0, FieldNum); + {_, 2} -> % 2 = length_delimited + read_gr_ld(Tl, 0, 0, NumBytes + TagLen1, FieldNum); + {_, 3} -> % 3 = group_start + read_gr_b(Tl, 0, 0, NumBytes + TagLen1, 0, FieldNum); + {_, 4} -> % 4 = group_end + read_gr_b(Tl, 0, 0, NumBytes + TagLen1, 0, FieldNum); + {_, 5} -> % 5 = bits32 + <<_:32, Tl2/binary>> = Tl, + read_gr_b(Tl2, 0, 0, NumBytes + TagLen1 + 4, 0, FieldNum) + end. + +read_gr_vi(<<1:1, _:7, Tl/binary>>, N, NumBytes, FieldNum) + when N < (64-7) -> + read_gr_vi(Tl, N+7, NumBytes+1, FieldNum); +read_gr_vi(<<0:1, _:7, Tl/binary>>, _, NumBytes, FieldNum) -> + read_gr_b(Tl, 0, 0, NumBytes+1, 0, FieldNum). + +read_gr_ld(<<1:1, X:7, Tl/binary>>, N, Acc, NumBytes, FieldNum) + when N < (64-7) -> + read_gr_ld(Tl, N+7, X bsl N + Acc, NumBytes+1, FieldNum); +read_gr_ld(<<0:1, X:7, Tl/binary>>, N, Acc, NumBytes, FieldNum) -> + Len = X bsl N + Acc, + NumBytes1 = NumBytes + 1, + <<_:Len/binary, Tl2/binary>> = Tl, + read_gr_b(Tl2, 0, 0, NumBytes1 + Len, 0, FieldNum). + +merge_msgs(Prev, New, MsgName) when is_atom(MsgName) -> merge_msgs(Prev, New, MsgName, []). + +merge_msgs(Prev, New, MsgName, Opts) -> + TrUserData = proplists:get_value(user_data, Opts), + case MsgName of 'Policy' -> merge_msg_Policy(Prev, New, TrUserData) end. + +-compile({nowarn_unused_function,merge_msg_Policy/3}). +merge_msg_Policy(#{} = PMsg, #{repository := NFrepository, name := NFname, visibility := NFvisibility} = NMsg, TrUserData) -> + S1 = #{repository => NFrepository, name => NFname, visibility => NFvisibility}, + S2 = case {PMsg, NMsg} of + {_, #{description := NFdescription}} -> S1#{description => NFdescription}; + {#{description := PFdescription}, _} -> S1#{description => PFdescription}; + _ -> S1 + end, + S3 = case {PMsg, NMsg} of + {_, #{advisory_min_severity := NFadvisory_min_severity}} -> S2#{advisory_min_severity => NFadvisory_min_severity}; + {#{advisory_min_severity := PFadvisory_min_severity}, _} -> S2#{advisory_min_severity => PFadvisory_min_severity}; + _ -> S2 + end, + S4 = case {PMsg, NMsg} of + {#{retirement_reasons := PFretirement_reasons}, #{retirement_reasons := NFretirement_reasons}} -> S3#{retirement_reasons => 'erlang_++'(PFretirement_reasons, NFretirement_reasons, TrUserData)}; + {_, #{retirement_reasons := NFretirement_reasons}} -> S3#{retirement_reasons => NFretirement_reasons}; + {#{retirement_reasons := PFretirement_reasons}, _} -> S3#{retirement_reasons => PFretirement_reasons}; + {_, _} -> S3 + end, + case {PMsg, NMsg} of + {_, #{cooldown := NFcooldown}} -> S4#{cooldown => NFcooldown}; + {#{cooldown := PFcooldown}, _} -> S4#{cooldown => PFcooldown}; + _ -> S4 + end. + + +verify_msg(Msg, MsgName) when is_atom(MsgName) -> verify_msg(Msg, MsgName, []). + +verify_msg(Msg, MsgName, Opts) -> + TrUserData = proplists:get_value(user_data, Opts), + case MsgName of + 'Policy' -> v_msg_Policy(Msg, [MsgName], TrUserData); + _ -> mk_type_error(not_a_known_message, Msg, []) + end. + + +-compile({nowarn_unused_function,v_msg_Policy/3}). +v_msg_Policy(#{repository := F1, name := F2, visibility := F4} = M, Path, TrUserData) -> + v_type_string(F1, [repository | Path], TrUserData), + v_type_string(F2, [name | Path], TrUserData), + case M of + #{description := F3} -> v_type_string(F3, [description | Path], TrUserData); + _ -> ok + end, + v_enum_Visibility(F4, [visibility | Path], TrUserData), + case M of + #{advisory_min_severity := F5} -> v_type_uint32(F5, [advisory_min_severity | Path], TrUserData); + _ -> ok + end, + case M of + #{retirement_reasons := F6} -> + if is_list(F6) -> + _ = [v_type_uint32(Elem, [retirement_reasons | Path], TrUserData) || Elem <- F6], + ok; + true -> mk_type_error({invalid_list_of, uint32}, F6, [retirement_reasons | Path]) + end; + _ -> ok + end, + case M of + #{cooldown := F7} -> v_type_string(F7, [cooldown | Path], TrUserData); + _ -> ok + end, + lists:foreach(fun (repository) -> ok; + (name) -> ok; + (description) -> ok; + (visibility) -> ok; + (advisory_min_severity) -> ok; + (retirement_reasons) -> ok; + (cooldown) -> ok; + (OtherKey) -> mk_type_error({extraneous_key, OtherKey}, M, Path) + end, + maps:keys(M)), + ok; +v_msg_Policy(M, Path, _TrUserData) when is_map(M) -> mk_type_error({missing_fields, [repository, name, visibility] -- maps:keys(M), 'Policy'}, M, Path); +v_msg_Policy(X, Path, _TrUserData) -> mk_type_error({expected_msg, 'Policy'}, X, Path). + +-compile({nowarn_unused_function,v_enum_Visibility/3}). +v_enum_Visibility('VISIBILITY_PRIVATE', _Path, _TrUserData) -> ok; +v_enum_Visibility('VISIBILITY_PUBLIC', _Path, _TrUserData) -> ok; +v_enum_Visibility(V, _Path, _TrUserData) when -2147483648 =< V, V =< 2147483647, is_integer(V) -> ok; +v_enum_Visibility(X, Path, _TrUserData) -> mk_type_error({invalid_enum, 'Visibility'}, X, Path). + +-compile({nowarn_unused_function,v_type_uint32/3}). +v_type_uint32(N, _Path, _TrUserData) when is_integer(N), 0 =< N, N =< 4294967295 -> ok; +v_type_uint32(N, Path, _TrUserData) when is_integer(N) -> mk_type_error({value_out_of_range, uint32, unsigned, 32}, N, Path); +v_type_uint32(X, Path, _TrUserData) -> mk_type_error({bad_integer, uint32, unsigned, 32}, X, Path). + +-compile({nowarn_unused_function,v_type_string/3}). +v_type_string(S, Path, _TrUserData) when is_list(S); is_binary(S) -> + try unicode:characters_to_binary(S) of + B when is_binary(B) -> ok; + {error, _, _} -> mk_type_error(bad_unicode_string, S, Path) + catch + error:badarg -> mk_type_error(bad_unicode_string, S, Path) + end; +v_type_string(X, Path, _TrUserData) -> mk_type_error(bad_unicode_string, X, Path). + +-compile({nowarn_unused_function,mk_type_error/3}). +-spec mk_type_error(_, _, list()) -> no_return(). +mk_type_error(Error, ValueSeen, Path) -> + Path2 = prettify_path(Path), + erlang:error({gpb_type_error, {Error, [{value, ValueSeen}, {path, Path2}]}}). + + +-compile({nowarn_unused_function,prettify_path/1}). +prettify_path([]) -> top_level; +prettify_path(PathR) -> string:join(lists:map(fun atom_to_list/1, lists:reverse(PathR)), "."). + + +-compile({nowarn_unused_function,id/2}). +-compile({inline,id/2}). +id(X, _TrUserData) -> X. + +-compile({nowarn_unused_function,v_ok/3}). +-compile({inline,v_ok/3}). +v_ok(_Value, _Path, _TrUserData) -> ok. + +-compile({nowarn_unused_function,m_overwrite/3}). +-compile({inline,m_overwrite/3}). +m_overwrite(_Prev, New, _TrUserData) -> New. + +-compile({nowarn_unused_function,cons/3}). +-compile({inline,cons/3}). +cons(Elem, Acc, _TrUserData) -> [Elem | Acc]. + +-compile({nowarn_unused_function,lists_reverse/2}). +-compile({inline,lists_reverse/2}). +'lists_reverse'(L, _TrUserData) -> lists:reverse(L). +-compile({nowarn_unused_function,'erlang_++'/3}). +-compile({inline,'erlang_++'/3}). +'erlang_++'(A, B, _TrUserData) -> A ++ B. + + +get_msg_defs() -> + [{{enum, 'Visibility'}, [{'VISIBILITY_PRIVATE', 0}, {'VISIBILITY_PUBLIC', 1}]}, + {{msg, 'Policy'}, + [#{name => repository, fnum => 1, rnum => 2, type => string, occurrence => required, opts => []}, + #{name => name, fnum => 2, rnum => 3, type => string, occurrence => required, opts => []}, + #{name => description, fnum => 3, rnum => 4, type => string, occurrence => optional, opts => []}, + #{name => visibility, fnum => 4, rnum => 5, type => {enum, 'Visibility'}, occurrence => required, opts => []}, + #{name => advisory_min_severity, fnum => 5, rnum => 6, type => uint32, occurrence => optional, opts => []}, + #{name => retirement_reasons, fnum => 6, rnum => 7, type => uint32, occurrence => repeated, opts => [packed]}, + #{name => cooldown, fnum => 7, rnum => 8, type => string, occurrence => optional, opts => []}]}]. + + +get_msg_names() -> ['Policy']. + + +get_group_names() -> []. + + +get_msg_or_group_names() -> ['Policy']. + + +get_enum_names() -> ['Visibility']. + + +fetch_msg_def(MsgName) -> + case find_msg_def(MsgName) of + Fs when is_list(Fs) -> Fs; + error -> erlang:error({no_such_msg, MsgName}) + end. + + +fetch_enum_def(EnumName) -> + case find_enum_def(EnumName) of + Es when is_list(Es) -> Es; + error -> erlang:error({no_such_enum, EnumName}) + end. + + +find_msg_def('Policy') -> + [#{name => repository, fnum => 1, rnum => 2, type => string, occurrence => required, opts => []}, + #{name => name, fnum => 2, rnum => 3, type => string, occurrence => required, opts => []}, + #{name => description, fnum => 3, rnum => 4, type => string, occurrence => optional, opts => []}, + #{name => visibility, fnum => 4, rnum => 5, type => {enum, 'Visibility'}, occurrence => required, opts => []}, + #{name => advisory_min_severity, fnum => 5, rnum => 6, type => uint32, occurrence => optional, opts => []}, + #{name => retirement_reasons, fnum => 6, rnum => 7, type => uint32, occurrence => repeated, opts => [packed]}, + #{name => cooldown, fnum => 7, rnum => 8, type => string, occurrence => optional, opts => []}]; +find_msg_def(_) -> error. + + +find_enum_def('Visibility') -> [{'VISIBILITY_PRIVATE', 0}, {'VISIBILITY_PUBLIC', 1}]; +find_enum_def(_) -> error. + + +enum_symbol_by_value('Visibility', Value) -> enum_symbol_by_value_Visibility(Value). + + +enum_value_by_symbol('Visibility', Sym) -> enum_value_by_symbol_Visibility(Sym). + + +enum_symbol_by_value_Visibility(0) -> 'VISIBILITY_PRIVATE'; +enum_symbol_by_value_Visibility(1) -> 'VISIBILITY_PUBLIC'. + + +enum_value_by_symbol_Visibility('VISIBILITY_PRIVATE') -> 0; +enum_value_by_symbol_Visibility('VISIBILITY_PUBLIC') -> 1. + + +get_service_names() -> []. + + +get_service_def(_) -> error. + + +get_rpc_names(_) -> error. + + +find_rpc_def(_, _) -> error. + + + +-spec fetch_rpc_def(_, _) -> no_return(). +fetch_rpc_def(ServiceName, RpcName) -> erlang:error({no_such_rpc, ServiceName, RpcName}). + + +%% Convert a a fully qualified (ie with package name) service name +%% as a binary to a service name as an atom. +-spec fqbin_to_service_name(_) -> no_return(). +fqbin_to_service_name(X) -> error({gpb_error, {badservice, X}}). + + +%% Convert a service name as an atom to a fully qualified +%% (ie with package name) name as a binary. +-spec service_name_to_fqbin(_) -> no_return(). +service_name_to_fqbin(X) -> error({gpb_error, {badservice, X}}). + + +%% Convert a a fully qualified (ie with package name) service name +%% and an rpc name, both as binaries to a service name and an rpc +%% name, as atoms. +-spec fqbins_to_service_and_rpc_name(_, _) -> no_return(). +fqbins_to_service_and_rpc_name(S, R) -> error({gpb_error, {badservice_or_rpc, {S, R}}}). + + +%% Convert a service name and an rpc name, both as atoms, +%% to a fully qualified (ie with package name) service name and +%% an rpc name as binaries. +-spec service_and_rpc_name_to_fqbins(_, _) -> no_return(). +service_and_rpc_name_to_fqbins(S, R) -> error({gpb_error, {badservice_or_rpc, {S, R}}}). + + +fqbin_to_msg_name(<<"Policy">>) -> 'Policy'; +fqbin_to_msg_name(E) -> error({gpb_error, {badmsg, E}}). + + +msg_name_to_fqbin('Policy') -> <<"Policy">>; +msg_name_to_fqbin(E) -> error({gpb_error, {badmsg, E}}). + + +fqbin_to_enum_name(<<"Visibility">>) -> 'Visibility'; +fqbin_to_enum_name(E) -> error({gpb_error, {badenum, E}}). + + +enum_name_to_fqbin('Visibility') -> <<"Visibility">>; +enum_name_to_fqbin(E) -> error({gpb_error, {badenum, E}}). + + +get_package_name() -> undefined. + + +%% Whether or not the message names +%% are prepended with package name or not. +uses_packages() -> false. + + +source_basename() -> "mix_hex_pb_policy.proto". + + +%% Retrieve all proto file names, also imported ones. +%% The order is top-down. The first element is always the main +%% source file. The files are returned with extension, +%% see get_all_proto_names/0 for a version that returns +%% the basenames sans extension +get_all_source_basenames() -> ["mix_hex_pb_policy.proto"]. + + +%% Retrieve all proto file names, also imported ones. +%% The order is top-down. The first element is always the main +%% source file. The files are returned sans .proto extension, +%% to make it easier to use them with the various get_xyz_containment +%% functions. +get_all_proto_names() -> ["mix_hex_pb_policy"]. + + +get_msg_containment("mix_hex_pb_policy") -> ['Policy']; +get_msg_containment(P) -> error({gpb_error, {badproto, P}}). + + +get_pkg_containment("mix_hex_pb_policy") -> undefined; +get_pkg_containment(P) -> error({gpb_error, {badproto, P}}). + + +get_service_containment("mix_hex_pb_policy") -> []; +get_service_containment(P) -> error({gpb_error, {badproto, P}}). + + +get_rpc_containment("mix_hex_pb_policy") -> []; +get_rpc_containment(P) -> error({gpb_error, {badproto, P}}). + + +get_enum_containment("mix_hex_pb_policy") -> ['Visibility']; +get_enum_containment(P) -> error({gpb_error, {badproto, P}}). + + +get_proto_by_msg_name_as_fqbin(<<"Policy">>) -> "mix_hex_pb_policy"; +get_proto_by_msg_name_as_fqbin(E) -> error({gpb_error, {badmsg, E}}). + + +-spec get_proto_by_service_name_as_fqbin(_) -> no_return(). +get_proto_by_service_name_as_fqbin(E) -> error({gpb_error, {badservice, E}}). + + +get_proto_by_enum_name_as_fqbin(<<"Visibility">>) -> "mix_hex_pb_policy"; +get_proto_by_enum_name_as_fqbin(E) -> error({gpb_error, {badenum, E}}). + + +-spec get_protos_by_pkg_name_as_fqbin(_) -> no_return(). +get_protos_by_pkg_name_as_fqbin(E) -> error({gpb_error, {badpkg, E}}). + + + +gpb_version_as_string() -> + "4.21.1". + +gpb_version_as_list() -> + [4,21,1]. + +gpb_version_source() -> + "file". diff --git a/src/mix_hex_pb_signed.erl b/src/mix_hex_pb_signed.erl index 3edb295b..05fab417 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_versions.erl b/src/mix_hex_pb_versions.erl index 26511274..6de9d0a1 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_registry.erl b/src/mix_hex_registry.erl index aba0a838..2d6cb4ad 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. @@ -16,6 +16,10 @@ decode_package/3, build_package/2, unpack_package/4, + encode_policy/1, + decode_policy/3, + build_policy/2, + unpack_policy/4, sign_protobuf/2, decode_signed/1, decode_and_verify_signed/2, @@ -118,6 +122,35 @@ decode_package(Payload, Repository, Package) -> {error, bad_repo_name} end. +%% @doc +%% Builds policy resource. +build_policy(Policy, PrivateKey) -> + Payload = encode_policy(Policy), + zlib:gzip(sign_protobuf(Payload, PrivateKey)). + +%% @doc +%% Unpacks policy resource. +unpack_policy(Payload, Repository, Name, PublicKey) -> + case decode_and_verify_signed(zlib:gunzip(Payload), PublicKey) of + {ok, Policy} -> decode_policy(Policy, Repository, Name); + Other -> Other + end. + +%% @private +encode_policy(Policy) -> + mix_hex_pb_policy:encode_msg(Policy, 'Policy'). + +%% @private +decode_policy(Payload, no_verify, no_verify) -> + {ok, mix_hex_pb_policy:decode_msg(Payload, 'Policy')}; +decode_policy(Payload, Repository, Name) -> + case mix_hex_pb_policy:decode_msg(Payload, 'Policy') of + #{repository := Repository, name := Name, visibility := _} = Result -> + {ok, Result}; + _ -> + {error, bad_repo_name} + end. + %% @private sign_protobuf(Payload, PrivateKey) -> Signature = sign(Payload, PrivateKey), diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index fc6fc99a..eb7925fe 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Repo API. @@ -7,6 +7,7 @@ get_names/1, get_versions/1, get_package/2, + get_policy/2, get_tarball/3, get_tarball_to_file/4, get_docs/3, @@ -90,6 +91,39 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) -> end, get_protobuf(Config, <<"packages/", Name/binary>>, Decoder). +%% @doc +%% Gets policy resource from the repository. +%% +%% Requires `repo_organization' to be set in the config; policies are +%% always served from the per-organization namespace +%% (`/repos//policies/'). +%% +%% Examples: +%% +%% ``` +%% > Config = (mix_hex_core:default_config())#{repo_organization => <<"myorg">>}, +%% > mix_hex_repo:get_policy(Config, <<"strict-prod">>). +%% {ok, {200, ..., +%% #{repository => <<"myorg">>, +%% name => <<"strict-prod">>, +%% visibility => 'VISIBILITY_PUBLIC'}}} +%% ''' +%% @end +get_policy(Config, Name) when is_binary(Name) and is_map(Config) -> + case maps:get(repo_organization, Config, undefined) of + undefined -> + {error, missing_repo_organization}; + Org when is_binary(Org) -> + Verify = maps:get(repo_verify_origin, Config, true), + Decoder = fun(Data) -> + case Verify of + true -> mix_hex_registry:decode_policy(Data, repo_name(Config), Name); + false -> mix_hex_registry:decode_policy(Data, no_verify, no_verify) + end + end, + get_protobuf(Config, <<"policies/", Name/binary>>, Decoder) + end. + %% @doc %% Gets tarball from the repository. %% diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index b8bfe0d2..54677d15 100644 --- a/src/mix_hex_safe_binary_to_term.erl +++ b/src/mix_hex_safe_binary_to_term.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @hidden %% Safe deserialization of Erlang terms from binary. diff --git a/src/mix_hex_tarball.erl b/src/mix_hex_tarball.erl index 242befcd..9feeaac7 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Functions for creating and unpacking Hex tarballs. diff --git a/src/mix_safe_erl_term.xrl b/src/mix_safe_erl_term.xrl index ef79a5c2..42e6ba0e 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. diff --git a/test/hex/cooldown_test.exs b/test/hex/cooldown_test.exs index 204b06f6..cd3960a6 100644 --- a/test/hex/cooldown_test.exs +++ b/test/hex/cooldown_test.exs @@ -211,6 +211,60 @@ defmodule Hex.CooldownTest do end end + describe "strictest/1" do + test "picks the candidate with the longest duration" do + assert {{"myorg", "p"}, "14d"} == + Cooldown.strictest([ + {:local, "7d"}, + {{"myorg", "p"}, "14d"}, + {{"myorg", "q"}, "3d"} + ]) + end + + test "treats nil and empty as zero" do + assert {:local, "7d"} == + Cooldown.strictest([ + {:local, "7d"}, + {{"myorg", "p"}, nil}, + {{"myorg", "q"}, ""} + ]) + end + + test "uses policy when local is 0d" do + assert {{"myorg", "p"}, "14d"} == + Cooldown.strictest([ + {:local, "0d"}, + {{"myorg", "p"}, "14d"} + ]) + end + + test "returns 0d when nothing is set" do + assert {:local, "0d"} == + Cooldown.strictest([ + {:local, "0d"}, + {{"myorg", "p"}, nil} + ]) + end + + test "returns :local when nothing else contributes" do + assert {:local, "7d"} == Cooldown.strictest([{:local, "7d"}]) + end + + test "normalizes nil/empty local to 0d" do + assert {{"myorg", "p"}, "14d"} == + Cooldown.strictest([ + {:local, nil}, + {{"myorg", "p"}, "14d"} + ]) + + assert {{"myorg", "p"}, "14d"} == + Cooldown.strictest([ + {:local, ""}, + {{"myorg", "p"}, "14d"} + ]) + end + end + describe "format_summary/2" do test "returns nil for an empty list" do Hex.State.put(:cooldown, "7d") diff --git a/test/hex/mix_task_test.exs b/test/hex/mix_task_test.exs index 4cefc4ca..312287ee 100644 --- a/test/hex/mix_task_test.exs +++ b/test/hex/mix_task_test.exs @@ -1216,10 +1216,10 @@ defmodule Hex.MixTaskTest 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. + # unsafe-locked-version bypass, `mix deps.update tired` would fail + # resolution 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 -> diff --git a/test/hex/policy/diagnostics_test.exs b/test/hex/policy/diagnostics_test.exs new file mode 100644 index 00000000..4fbc193b --- /dev/null +++ b/test/hex/policy/diagnostics_test.exs @@ -0,0 +1,82 @@ +defmodule Hex.Policy.DiagnosticsTest do + use HexTest.Case + alias Hex.Policy.Diagnostics + + defp policy(opts) do + Map.merge( + %{repository: "myorg", visibility: :VISIBILITY_PUBLIC}, + Map.new(opts) + ) + end + + describe "resolution_summary/3" do + test "returns nil when no policies are loaded" do + assert Diagnostics.resolution_summary([], [], "0d") == nil + end + + test "returns a header + cooldown line + per-policy counts" do + policies = [ + policy(name: "strict-prod", cooldown: "14d"), + policy(name: "baseline") + ] + + filtered = [ + %{ + repo: "hexpm", + package: "phoenix", + version: "1.7.18", + blockers: [%{policy: hd(policies), reason: {:advisory, :SEVERITY_HIGH}}] + } + ] + + out = Diagnostics.resolution_summary(policies, filtered, "0d") + assert out =~ "Active policies: myorg/baseline, myorg/strict-prod (2)" + assert out =~ "Effective cooldown: 14d" + assert out =~ "Policies hid 1 candidate versions" + assert out =~ "myorg/strict-prod: 1 blocked" + end + end + + describe "failure_note/1" do + test "returns nil when nothing filtered" do + assert Diagnostics.failure_note([]) == nil + end + + test "groups by package and lists blockers" do + pol = policy(name: "strict-prod") + + out = + Diagnostics.failure_note([ + %{ + repo: "hexpm", + package: "decimal", + version: "2.0.0", + blockers: [%{policy: pol, reason: {:retirement, :RETIRED_SECURITY}}] + }, + %{ + repo: "hexpm", + package: "decimal", + version: "2.0.1", + blockers: [%{policy: pol, reason: {:advisory, :SEVERITY_HIGH}}] + } + ]) + + assert out =~ "Note: active policies hide 2 versions of \"decimal\"" + assert out =~ "decimal 2.0.0" + assert out =~ "decimal 2.0.1" + assert out =~ "advisory ≥ HIGH" + assert out =~ "retirement: security" + end + end + + describe "format_load_error/1" do + test "invalid_policy_config" do + assert Diagnostics.format_load_error(:invalid_policy_config) =~ + "Policy configuration is invalid" + end + + test "fallback" do + assert Diagnostics.format_load_error(:something) =~ "Policy loading failed" + end + end +end diff --git a/test/hex/policy/filter_test.exs b/test/hex/policy/filter_test.exs new file mode 100644 index 00000000..cdecd5da --- /dev/null +++ b/test/hex/policy/filter_test.exs @@ -0,0 +1,107 @@ +defmodule Hex.Policy.FilterTest do + use HexTest.Case + alias Hex.Policy.Filter + + defp policy(overrides \\ %{}) do + Map.merge( + %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC + }, + overrides + ) + end + + defp release(overrides \\ %{}) do + Map.merge(%{version: "1.0.0", advisories: []}, overrides) + end + + describe "classify/3 — advisory rule" do + test "blocks when release advisory >= threshold" do + p = policy(%{advisory_min_severity: 3}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) + assert {:blocked, reasons} = Filter.classify(p, r, []) + assert {:advisory, :SEVERITY_HIGH} in reasons + end + + test "allows when release advisory < threshold" do + p = policy(%{advisory_min_severity: 3}) + r = release(%{advisories: [%{severity: :SEVERITY_LOW}]}) + assert :allowed == Filter.classify(p, r, []) + end + + test "allows when no advisories" do + p = policy(%{advisory_min_severity: 3}) + r = release(%{advisories: []}) + assert :allowed == Filter.classify(p, r, []) + end + + test "allows when policy has no advisory rule" do + p = policy() + r = release(%{advisories: [%{severity: :SEVERITY_CRITICAL}]}) + assert :allowed == Filter.classify(p, r, []) + end + end + + describe "classify/3 — retirement rule" do + test "blocks when release retired with selected reason" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release(%{retired: %{reason: :RETIRED_SECURITY}}) + assert {:blocked, reasons} = Filter.classify(p, r, []) + assert {:retirement, :RETIRED_SECURITY} in reasons + end + + test "allows when reason not in set" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release(%{retired: %{reason: :RETIRED_RENAMED}}) + assert :allowed == Filter.classify(p, r, []) + end + + test "allows when not retired" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release() + assert :allowed == Filter.classify(p, r, []) + end + + test "allows when policy has no retirement rule" do + p = policy() + r = release(%{retired: %{reason: :RETIRED_SECURITY}}) + assert :allowed == Filter.classify(p, r, []) + end + end + + describe "classify_set/3" do + test "blocks if any policy blocks" do + p1 = policy(%{name: "a", advisory_min_severity: 3}) + p2 = policy(%{name: "b", advisory_min_severity: 4}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) + + assert {:blocked, blockers} = Filter.classify_set([p1, p2], r) + assert length(blockers) == 1 + assert hd(blockers).policy.name == "a" + end + + test "lists every blocking policy when multiple block" do + p1 = policy(%{name: "a", advisory_min_severity: 3}) + p2 = policy(%{name: "b", advisory_min_severity: 2}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) + + assert {:blocked, blockers} = Filter.classify_set([p1, p2], r) + assert length(blockers) == 2 + assert Enum.any?(blockers, &(&1.policy.name == "a")) + assert Enum.any?(blockers, &(&1.policy.name == "b")) + end + + test "allows when all policies allow" do + p1 = policy(%{name: "a", advisory_min_severity: 4}) + p2 = policy(%{name: "b", advisory_min_severity: 4}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) + assert :allowed == Filter.classify_set([p1, p2], r) + end + + test "allows with no policies" do + assert :allowed == Filter.classify_set([], release()) + end + end +end diff --git a/test/hex/policy/sources_test.exs b/test/hex/policy/sources_test.exs new file mode 100644 index 00000000..0bff84bd --- /dev/null +++ b/test/hex/policy/sources_test.exs @@ -0,0 +1,84 @@ +defmodule Hex.Policy.SourcesTest do + use HexTest.Case, async: false + alias Hex.Policy.Sources + + describe "parse_config/1" do + test "accepts a single keyword list" do + assert {:ok, [{"myorg", "strict-prod"}]} == + Sources.parse_config(repo: "myorg", name: "strict-prod") + end + + test "accepts a list of keyword lists" do + assert {:ok, [{"acme", "a"}, {"acme", "b"}]} == + Sources.parse_config([ + [repo: "acme", name: "a"], + [repo: "acme", name: "b"] + ]) + end + + test "accepts a comma-separated string (env-var form)" do + assert {:ok, [{"myorg", "strict-prod"}]} == Sources.parse_config("myorg/strict-prod") + + assert {:ok, [{"myorg", "strict-prod"}, {"acme", "baseline"}]} == + Sources.parse_config("myorg/strict-prod,acme/baseline") + end + + test "treats nil and empty as no policies" do + assert {:ok, []} == Sources.parse_config(nil) + assert {:ok, []} == Sources.parse_config("") + assert {:ok, []} == Sources.parse_config([]) + end + + test "rejects malformed entries" do + assert :error == Sources.parse_config("missing-slash") + assert :error == Sources.parse_config("a/b/c") + assert :error == Sources.parse_config(repo: "x") + assert :error == Sources.parse_config(42) + end + + test "rejects the bare hexpm repo (no organization scope)" do + assert :error == Sources.parse_config("hexpm/strict") + assert :error == Sources.parse_config(repo: "hexpm", name: "strict") + assert :error == Sources.parse_config([[repo: "hexpm", name: "strict"]]) + end + + test "trims whitespace in env-var form" do + assert {:ok, [{"myorg", "p"}, {"acme", "b"}]} == + Sources.parse_config(" myorg/p , acme/b ") + end + end + + describe "dedup/1" do + test "dedups by (repo, name) preserving first-seen order" do + assert [{"acme", "a"}, {"myorg", "b"}] == + Sources.dedup([{"acme", "a"}, {"myorg", "b"}, {"acme", "a"}]) + end + + test "is a no-op on already-unique input" do + assert [{"acme", "a"}, {"acme", "b"}] == Sources.dedup([{"acme", "a"}, {"acme", "b"}]) + end + end + + describe "load_all/0" do + test "returns the empty set when no source contributes" do + Hex.State.put(:policy_env, []) + Hex.State.put(:policy_project, []) + Hex.State.put(:policy_global, []) + assert {:ok, []} == Sources.load_all() + end + + test "unions and dedups refs across all three sources" do + # Env comes first; project next; global last. Dedup keeps first-seen. + Hex.State.put(:policy_env, [{"myorg", "strict"}, {"acme", "extra"}]) + Hex.State.put(:policy_project, [{"acme", "extra"}]) + Hex.State.put(:policy_global, [{"acme", "baseline"}, {"myorg", "strict"}]) + + assert {:ok, + [ + {"myorg", "strict"}, + {"acme", "extra"}, + {"acme", "baseline"} + ]} == Sources.load_all() + end + end +end diff --git a/test/hex/registry/policy_test.exs b/test/hex/registry/policy_test.exs new file mode 100644 index 00000000..bd5d2be1 --- /dev/null +++ b/test/hex/registry/policy_test.exs @@ -0,0 +1,123 @@ +defmodule Hex.Registry.PolicyTest do + use HexTest.Case + alias Hex.Registry.Policy + alias Hex.Registry.Server + + setup do + registry = [ + {:hexpm, :clean, "1.0.0", []}, + {:hexpm, :clean, "1.1.0", []}, + {:hexpm, :advised, "1.0.0", []}, + {:hexpm, :advised, "1.1.0", []}, + {:hexpm, :retired, "1.0.0", []}, + {:hexpm, :retired, "1.1.0", []} + ] + + advisories = [ + {{"hexpm", "advised", "1.0.0"}, + [%{id: "GHSA-test-aaaa-bbbb", summary: "test", severity: :SEVERITY_HIGH}]} + ] + + retired = %{ + {:hexpm, :retired, "1.0.0"} => %{reason: :RETIRED_SECURITY, message: "CVE"} + } + + path = tmp_path("cache_policy.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", "clean"}, {"hexpm", "advised"}, {"hexpm", "retired"}]) + + # Reset diagnostics for each test + Hex.State.put(:policy_filtered_versions, []) + Hex.State.put(:policies, %{}) + + # Disable cooldown so Hex.Registry.Cooldown is a no-op pass-through + 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 + end + + test "passes through when no policies are configured" do + assert {:ok, versions} = Policy.versions("hexpm", "clean") + {:ok, expected} = Server.versions("hexpm", "clean") + assert versions == expected + end + + test "passes through when no policies are configured for a package with advisories" do + assert {:ok, versions} = Policy.versions("hexpm", "advised") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0"] + assert Hex.State.fetch!(:policy_filtered_versions) == [] + end + + test "filters versions that an advisory rule blocks" do + Hex.State.put(:policies, %{ + {"hexpm:myorg", "strict"} => %{ + repository: "myorg", + name: "strict", + visibility: :VISIBILITY_PUBLIC, + advisory_min_severity: 3 + } + }) + + assert {:ok, versions} = Policy.versions("hexpm", "advised") + assert Enum.map(versions, &to_string/1) == ["1.1.0"] + + [entry] = Hex.State.fetch!(:policy_filtered_versions) + assert entry.repo == "hexpm" + assert entry.package == "advised" + assert entry.version == "1.0.0" + + assert [%{policy: %{name: "strict"}, reason: {:advisory, :SEVERITY_HIGH}}] = + entry.blockers + end + + test "filters versions that a retirement rule blocks" do + Hex.State.put(:policies, %{ + {"hexpm:myorg", "no-security-retired"} => %{ + repository: "myorg", + name: "no-security-retired", + visibility: :VISIBILITY_PUBLIC, + retirement_reasons: [2] + } + }) + + assert {:ok, versions} = Policy.versions("hexpm", "retired") + assert Enum.map(versions, &to_string/1) == ["1.1.0"] + + [entry] = Hex.State.fetch!(:policy_filtered_versions) + assert entry.package == "retired" + assert entry.version == "1.0.0" + + assert [ + %{policy: %{name: "no-security-retired"}, reason: {:retirement, :RETIRED_SECURITY}} + ] = entry.blockers + end + + test "no diagnostics recorded when nothing is blocked" do + Hex.State.put(:policies, %{ + {"hexpm:myorg", "strict"} => %{ + repository: "myorg", + name: "strict", + visibility: :VISIBILITY_PUBLIC, + advisory_min_severity: 3 + } + }) + + assert {:ok, versions} = Policy.versions("hexpm", "clean") + assert Enum.map(versions, &to_string/1) == ["1.0.0", "1.1.0"] + assert Hex.State.fetch!(:policy_filtered_versions) == [] + end + + test "delegates prefetch and dependencies" do + Code.ensure_loaded!(Policy) + assert function_exported?(Policy, :prefetch, 1) + assert function_exported?(Policy, :dependencies, 3) + end +end diff --git a/test/hex/registry/server_policy_test.exs b/test/hex/registry/server_policy_test.exs new file mode 100644 index 00000000..742bc1f1 --- /dev/null +++ b/test/hex/registry/server_policy_test.exs @@ -0,0 +1,97 @@ +defmodule Hex.Registry.ServerPolicyTest do + use HexTest.Case, async: false + alias Hex.Registry.Server, as: Registry + + setup do + bypass = Bypass.open() + repos = Hex.State.fetch!(:repos) + + repos = + Map.put(repos, "hexpm:myorg", %{ + url: "http://localhost:#{bypass.port}/repos/myorg", + public_key: File.read!(fixture_path("test_pub.pem")), + auth_key: "key", + trusted: true, + oauth_exchange: false, + organization: "myorg" + }) + + Hex.State.put(:repos, repos) + {:ok, bypass: bypass} + end + + defp signed_policy(fields) do + private_key = File.read!(fixture_path("test_priv.pem")) + payload = :mix_hex_registry.encode_policy(Map.new(fields)) + signed = :mix_hex_registry.sign_protobuf(payload, private_key) + :zlib.gzip(signed) + end + + defp fresh_policy(extra) do + base = %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC + } + + signed_policy(Map.merge(base, Map.new(extra))) + end + + test "prefetch_policies/1 fetches and decodes; policy/2 returns the decoded map", + %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/repos/myorg/policies/strict-prod", fn conn -> + conn + |> Plug.Conn.put_resp_header("etag", "\"v1\"") + |> Plug.Conn.resp(200, fresh_policy(advisory_min_severity: 3)) + end) + + in_tmp("registry_policy_fetch", fn -> + Hex.State.put(:cache_home, File.cwd!()) + Registry.open(check_version: false, registry_path: Path.join(File.cwd!(), "cache.ets")) + + assert :ok = Registry.prefetch_policies([{"hexpm:myorg", "strict-prod"}]) + assert {:ok, policy} = Registry.policy("hexpm:myorg", "strict-prod") + assert policy.name == "strict-prod" + assert policy.advisory_min_severity == 3 + end) + end + + test "policy/2 falls back to the cached payload when the registry is unreachable", + %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/repos/myorg/policies/strict-prod", fn conn -> + Plug.Conn.resp(conn, 200, fresh_policy([])) + end) + + in_tmp("registry_policy_cache_fallback", fn -> + Hex.State.put(:cache_home, File.cwd!()) + registry_path = Path.join(File.cwd!(), "cache.ets") + Registry.open(check_version: false, registry_path: registry_path) + + assert :ok = Registry.prefetch_policies([{"hexpm:myorg", "strict-prod"}]) + assert {:ok, _} = Registry.policy("hexpm:myorg", "strict-prod") + + Registry.persist() + Registry.close() + Bypass.down(bypass) + + Registry.open(check_version: false, registry_path: registry_path) + assert :ok = Registry.prefetch_policies([{"hexpm:myorg", "strict-prod"}]) + assert {:ok, policy} = Registry.policy("hexpm:myorg", "strict-prod") + assert policy.name == "strict-prod" + end) + end + + test "prefetch_policies/1 raises a helpful error in offline mode when missing" do + in_tmp("registry_policy_offline", fn -> + Hex.State.put(:cache_home, File.cwd!()) + Hex.State.put(:offline, true) + Registry.open(check_version: false, registry_path: Path.join(File.cwd!(), "cache.ets")) + + assert_raise Mix.Error, + ~r"Hex is running in offline mode and policy hexpm:myorg/strict-prod is not cached locally", + fn -> + Registry.prefetch_policies([{"hexpm:myorg", "strict-prod"}]) + end + end) + end +end diff --git a/test/hex/remote_converger_cooldown_test.exs b/test/hex/remote_converger_cooldown_test.exs index 9cac26d4..50fca3b2 100644 --- a/test/hex/remote_converger_cooldown_test.exs +++ b/test/hex/remote_converger_cooldown_test.exs @@ -1,8 +1,10 @@ defmodule Hex.RemoteConvergerCooldownTest do - # Non-integration tests for the cooldown helpers in Hex.RemoteConverger. - # The full integration scenarios live under HexTest.IntegrationCase. + # Non-integration tests for cooldown setup driven from + # `Hex.RemoteConverger`. The full integration scenarios live under + # HexTest.IntegrationCase. use HexTest.Case + alias Hex.Cooldown alias Hex.RemoteConverger alias Hex.Registry.Server @@ -36,6 +38,8 @@ defmodule Hex.RemoteConvergerCooldownTest do Server.open(registry_path: path) Server.prefetch([{"hexpm", "good"}, {"hexpm", "retired_dep"}, {"hexpm", "advised_dep"}]) + Hex.State.put(:policies, %{}) + Hex.State.put(:cooldown, "7d") :ok end @@ -47,23 +51,29 @@ defmodule Hex.RemoteConvergerCooldownTest do %{repo: repo, name: name, app: name, version: version} end - @cutoff {:cutoff, System.system_time(:second) - 7 * 86_400, 7 * 86_400} + defp setup_and_bypass(old_lock, locked) do + Cooldown.setup(old_lock, locked) + Hex.State.fetch!(:cooldown_bypass_packages) + end - describe "build_cooldown_bypass/3 — disabled" do - test "returns empty set when cutoff is :disabled" do + describe "Cooldown.setup/2 — disabled" do + test "returns empty bypass set when cutoff is :disabled" do + Hex.State.put(:cooldown, "0d") old_lock = %{retired_dep: lock_tuple("retired_dep", "1.0.0")} - assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, [], :disabled) + + assert MapSet.new() == setup_and_bypass(old_lock, []) + assert :disabled == Hex.State.fetch!(:cooldown_cutoff) end end - describe "build_cooldown_bypass/3 — unsafe-locked-version bypass" do + describe "Cooldown.setup/2 — 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) + bypass = setup_and_bypass(old_lock, []) assert MapSet.member?(bypass, "retired_dep") refute MapSet.member?(bypass, "good") @@ -75,24 +85,24 @@ defmodule Hex.RemoteConvergerCooldownTest do # — 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) + bypass = setup_and_bypass(old_lock, []) 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) + bypass = setup_and_bypass(old_lock, []) 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) + assert MapSet.new() == setup_and_bypass(old_lock, []) end end - describe "build_cooldown_bypass/3 — lock-satisfied bypass (spec C)" do + describe "Cooldown.setup/2 — 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 @@ -101,7 +111,7 @@ defmodule Hex.RemoteConvergerCooldownTest do 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) + bypass = setup_and_bypass(old_lock, locked) assert MapSet.member?(bypass, "good") end @@ -112,7 +122,7 @@ defmodule Hex.RemoteConvergerCooldownTest do old_lock = %{good: lock_tuple("good", "1.0.0")} locked = [] - assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + assert MapSet.new() == setup_and_bypass(old_lock, locked) end test "new top-level deps (not in old_lock) are not bypassed" do @@ -120,15 +130,24 @@ defmodule Hex.RemoteConvergerCooldownTest do # Cooldown filtering applies normally to fresh additions. old_lock = %{} locked = [] - assert MapSet.new() == RemoteConverger.build_cooldown_bypass(old_lock, locked, @cutoff) + assert MapSet.new() == setup_and_bypass(old_lock, locked) + end + end + + describe "Cooldown.setup/2 — locked versions exemption" do + test "records the lock-pinned version for every locked package" do + old_lock = %{good: lock_tuple("good", "1.0.0")} + Cooldown.setup(old_lock, []) + + assert %{{"hexpm", "good"} => ["1.0.0"]} == + Hex.State.fetch!(:cooldown_locked_versions) 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_cutoff, Cooldown.build_cutoff()) Hex.State.put(:cooldown_filtered_versions, [ {"hexpm", "castore", "1.0.19", now - 6 * 86_400} @@ -142,8 +161,7 @@ defmodule Hex.RemoteConvergerCooldownTest do 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_cutoff, Cooldown.build_cutoff()) Hex.State.put(:cooldown_filtered_versions, []) RemoteConverger.print_cooldown_summary() @@ -162,19 +180,13 @@ defmodule Hex.RemoteConvergerCooldownTest do 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. + # Confirms the wrapper consumes the bypass set Cooldown.setup/2 writes: + # an advisory-flagged locked version actually unblocks the upgrade path, + # not only that the right MapSet was produced. 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, %{}) + Cooldown.setup(old_lock, []) Hex.State.put(:cooldown_filtered_versions, []) # Without bypass the wrapper would filter every version (no published_at @@ -187,12 +199,7 @@ defmodule Hex.RemoteConvergerCooldownTest do 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, %{}) + Cooldown.setup(old_lock, []) Hex.State.put(:cooldown_filtered_versions, []) {:ok, versions} = Hex.Registry.Cooldown.versions("hexpm", "retired_dep") diff --git a/test/hex/remote_converger_policy_test.exs b/test/hex/remote_converger_policy_test.exs new file mode 100644 index 00000000..a40e7576 --- /dev/null +++ b/test/hex/remote_converger_policy_test.exs @@ -0,0 +1,21 @@ +defmodule Hex.RemoteConvergerPolicyTest do + use HexTest.Case + + test "with no policies configured, Hex.Policy.load_all returns empty" do + in_tmp("remote_converger_no_policy", fn -> + Hex.State.put(:config_home, File.cwd!()) + original = System.get_env("HEX_POLICY") + System.delete_env("HEX_POLICY") + + try do + Hex.State.refresh() + assert {:ok, %{}} = Hex.Policy.load_all() + after + case original do + nil -> System.delete_env("HEX_POLICY") + value -> System.put_env("HEX_POLICY", value) + end + end + end) + end +end diff --git a/test/mix/tasks/hex.policy_test.exs b/test/mix/tasks/hex.policy_test.exs new file mode 100644 index 00000000..44380810 --- /dev/null +++ b/test/mix/tasks/hex.policy_test.exs @@ -0,0 +1,73 @@ +defmodule Mix.Tasks.Hex.PolicyTest do + use HexTest.Case, async: false + import ExUnit.CaptureIO + + setup do + Hex.State.put(:policies, %{}) + Mix.shell(Mix.Shell.IO) + on_exit(fn -> Mix.shell(Hex.Shell.Process) end) + :ok + end + + describe "show" do + test "prints 'no active policies' message when empty" do + out = capture_io(fn -> Mix.Tasks.Hex.Policy.run(["show"]) end) + assert out =~ "No active policies" + end + + test "prints active set with key fields" do + Hex.State.put(:policies, %{ + {"hexpm:myorg", "strict-prod"} => %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC, + cooldown: "14d", + advisory_min_severity: 3, + retirement_reasons: [1, 2] + } + }) + + out = capture_io(fn -> Mix.Tasks.Hex.Policy.run(["show"]) end) + assert out =~ "Active policies" + assert out =~ "myorg/strict-prod" + assert out =~ "public" + assert out =~ "14d" + assert out =~ "HIGH" + end + + test "default (no subcommand) is show" do + out = capture_io(fn -> Mix.Tasks.Hex.Policy.run([]) end) + assert out =~ "No active policies" || out =~ "Active policies" + end + end + + describe "why" do + test "complains when package name is missing" do + assert_raise Mix.Error, ~r/Usage|usage|why/, fn -> + Mix.Tasks.Hex.Policy.run(["why"]) + end + end + + test "rejects empty halves like myorg/ or /pkg" do + Hex.State.put(:policies, %{ + {"hexpm:myorg", "strict-prod"} => %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC + } + }) + + assert_raise Mix.Error, ~r/Invalid package argument/, fn -> + Mix.Tasks.Hex.Policy.run(["why", "myorg/"]) + end + + assert_raise Mix.Error, ~r/Invalid package argument/, fn -> + Mix.Tasks.Hex.Policy.run(["why", "/foo"]) + end + end + + # Note: a full `why ` test would require a registry fixture with + # advisory metadata. Skip for now; the rendering surface is covered + # indirectly by Hex.Policy.Filter and Hex.Policy.Diagnostics tests. + end +end