From 9529fb27e5fab10451a35960414953cb200fae8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 01:35:35 +0200 Subject: [PATCH 01/25] Vendor hex_core with Policy resource support Adds mix_hex_pb_policy generated module and extends the vendoring script to track hex_pb_policy. mix_hex_registry gains encode_policy/ decode_policy/build_policy/unpack_policy and mix_hex_repo gains get_policy/2, mirroring the recently-added hex_core API. --- scripts/vendor_hex_core.sh | 2 + src/mix_hex_advisory.erl | 2 +- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 2 +- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_policy.erl | 814 ++++++++++++++++++++++++ src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 35 +- src/mix_hex_repo.erl | 39 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- 30 files changed, 914 insertions(+), 28 deletions(-) create mode 100644 src/mix_hex_pb_policy.erl 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..116e29f4 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 (69af1f5), 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..f28abe94 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 (69af1f5), 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..95717f95 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 (69af1f5), 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..c577ac36 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 (69af1f5), 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..92a42e81 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 (69af1f5), 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..f6c6cc03 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 (69af1f5), 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..bee811cd 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 (69af1f5), 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..daf6dd36 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 (69af1f5), 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..d898e1b4 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 (69af1f5), 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..8e70c8df 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 (69af1f5), 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..7f75b6a1 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 (69af1f5), 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..fa3936e3 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 (69af1f5), 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..e25f1d85 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 (69af1f5), 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..86a52b9e 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 (69af1f5), 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..5c5364ee 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 (69af1f5), 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..7286df42 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 (69af1f5), 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..70513b6f 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 (69af1f5), 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..6f22d848 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 (69af1f5), 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..a2541bb8 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 (69af1f5), 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..37fd889a 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 (69af1f5), 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..b4cd4413 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 (69af1f5), 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..e0351996 --- /dev/null +++ b/src/mix_hex_pb_policy.erl @@ -0,0 +1,814 @@ +%% Vendored from hex_core v0.17.0 (69af1f5), 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 + published_at => integer(), % = 4, required, 64 bits + visibility => 'VISIBILITY_PRIVATE' | 'VISIBILITY_PUBLIC' | integer(), % = 5, required, enum Visibility + advisory_min_severity => non_neg_integer(), % = 6, optional, 32 bits + retirement_reasons => [non_neg_integer()], % = 7, repeated, 32 bits + cooldown => unicode:chardata() % = 8, 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, published_at := F4, visibility := F5} = 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_type_int64(TrF4, <>, TrUserData) end, + B5 = begin TrF5 = id(F5, TrUserData), e_enum_Visibility(TrF5, <>, TrUserData) end, + B6 = case M of + #{advisory_min_severity := F6} -> begin TrF6 = id(F6, TrUserData), e_varint(TrF6, <>, TrUserData) end; + _ -> B5 + end, + B7 = case M of + #{retirement_reasons := F7} -> + TrF7 = id(F7, TrUserData), + if TrF7 == [] -> B6; + true -> e_field_Policy_retirement_reasons(TrF7, B6, TrUserData) + end; + _ -> B6 + end, + case M of + #{cooldown := F8} -> begin TrF8 = id(F8, TrUserData), e_type_string(TrF8, <>, TrUserData) end; + _ -> B7 + 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('$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, F@_8, TrUserData) -> d_field_Policy_repository(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_name(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_description(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_published_at(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_visibility(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData) -> d_pfield_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); +dfp_read_field_def_Policy(<<56, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> d_field_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); +dfp_read_field_def_Policy(<<66, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> d_field_Policy_cooldown(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); +dfp_read_field_def_Policy(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, R1, F@_8, TrUserData) -> + S1 = #{repository => F@_1, name => F@_2, published_at => F@_4, visibility => F@_5, retirement_reasons => lists_reverse(R1, TrUserData)}, + S2 = if F@_3 == '$undef' -> S1; + true -> S1#{description => F@_3} + end, + S3 = if F@_6 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_6} + end, + if F@_8 == '$undef' -> S3; + true -> S3#{cooldown => F@_8} + end; +dfp_read_field_def_Policy(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> dg_read_field_def_Policy(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData); + 18 -> d_field_Policy_name(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 26 -> d_field_Policy_description(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 32 -> d_field_Policy_published_at(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 40 -> d_field_Policy_visibility(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 48 -> d_field_Policy_advisory_min_severity(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 58 -> d_pfield_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 56 -> d_field_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 66 -> d_field_Policy_cooldown(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData) + end + end; +dg_read_field_def_Policy(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, R1, F@_8, TrUserData) -> + S1 = #{repository => F@_1, name => F@_2, published_at => F@_4, visibility => F@_5, retirement_reasons => lists_reverse(R1, TrUserData)}, + S2 = if F@_3 == '$undef' -> S1; + true -> S1#{description => F@_3} + end, + S3 = if F@_6 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_6} + end, + if F@_8 == '$undef' -> S3; + true -> S3#{cooldown => F@_8} + 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData). + +d_field_Policy_published_at(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) when N < 57 -> + d_field_Policy_published_at(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); +d_field_Policy_published_at(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, _, F@_5, F@_6, F@_7, F@_8, TrUserData) -> + {NewFValue, RestF} = {begin <> = <<(X bsl N + Acc):64/unsigned-native>>, id(Res, TrUserData) end, Rest}, + dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, NewFValue, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, TrUserData); +d_field_Policy_visibility(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, F@_8, 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, F@_4, NewFValue, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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@_5, _, F@_7, F@_8, 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, NewFValue, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_6, Prev, F@_8, 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, F@_6, cons(NewFValue, Prev, TrUserData), F@_8, 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, F@_8, 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, F@_8, 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, F@_6, E, F@_8, 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, F@_6, NewSeq, F@_8, 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, F@_8, 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, F@_8, 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, 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, F@_3, F@_4, F@_5, F@_6, F@_7, 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, F@_8, TrUserData) -> skip_varint_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData). + +skip_group_Policy(Bin, _, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData). + +skip_32_Policy(<<_:32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData). + +skip_64_Policy(<<_:64, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, published_at := NFpublished_at, visibility := NFvisibility} = NMsg, TrUserData) -> + S1 = #{repository => NFrepository, name => NFname, published_at => NFpublished_at, 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, published_at := F4, visibility := F5} = 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_type_int64(F4, [published_at | Path], TrUserData), + v_enum_Visibility(F5, [visibility | Path], TrUserData), + case M of + #{advisory_min_severity := F6} -> v_type_uint32(F6, [advisory_min_severity | Path], TrUserData); + _ -> ok + end, + case M of + #{retirement_reasons := F7} -> + if is_list(F7) -> + _ = [v_type_uint32(Elem, [retirement_reasons | Path], TrUserData) || Elem <- F7], + ok; + true -> mk_type_error({invalid_list_of, uint32}, F7, [retirement_reasons | Path]) + end; + _ -> ok + end, + case M of + #{cooldown := F8} -> v_type_string(F8, [cooldown | Path], TrUserData); + _ -> ok + end, + lists:foreach(fun (repository) -> ok; + (name) -> ok; + (description) -> ok; + (published_at) -> 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, published_at, 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_int64/3}). +v_type_int64(N, _Path, _TrUserData) when is_integer(N), -9223372036854775808 =< N, N =< 9223372036854775807 -> ok; +v_type_int64(N, Path, _TrUserData) when is_integer(N) -> mk_type_error({value_out_of_range, int64, signed, 64}, N, Path); +v_type_int64(X, Path, _TrUserData) -> mk_type_error({bad_integer, int64, signed, 64}, 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 => published_at, fnum => 4, rnum => 5, type => int64, occurrence => required, opts => []}, + #{name => visibility, fnum => 5, rnum => 6, type => {enum, 'Visibility'}, occurrence => required, opts => []}, + #{name => advisory_min_severity, fnum => 6, rnum => 7, type => uint32, occurrence => optional, opts => []}, + #{name => retirement_reasons, fnum => 7, rnum => 8, type => uint32, occurrence => repeated, opts => [packed]}, + #{name => cooldown, fnum => 8, rnum => 9, 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 => published_at, fnum => 4, rnum => 5, type => int64, occurrence => required, opts => []}, + #{name => visibility, fnum => 5, rnum => 6, type => {enum, 'Visibility'}, occurrence => required, opts => []}, + #{name => advisory_min_severity, fnum => 6, rnum => 7, type => uint32, occurrence => optional, opts => []}, + #{name => retirement_reasons, fnum => 7, rnum => 8, type => uint32, occurrence => repeated, opts => [packed]}, + #{name => cooldown, fnum => 8, rnum => 9, 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..a4c37312 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 (69af1f5), 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..c23ce3be 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 (69af1f5), 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..865c9bbf 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 (69af1f5), 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} = 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..a5e631a7 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 (69af1f5), 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,42 @@ 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, + "mix_hex_repo:get_policy/2 requires repo_organization to be set"} + ); + 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..53cba9a8 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 (69af1f5), 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..c5f94770 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 (69af1f5), 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..085452e6 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 (69af1f5), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. From 927389db7586157010231b0a78cc813a296fa782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 12:52:22 +0200 Subject: [PATCH 02/25] Add policy configuration plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the :policy slot in Hex.State (env HEX_POLICY, config :policy, default []). Hex.Policy.Sources parses the three accepted shapes — keyword list, list of keyword lists, comma-separated string — and deduplicates by (repo, name) preserving order. Hex.Repo gains get_policy/2 wrapping the vendored :mix_hex_repo.get_policy/2. --- lib/hex/policy/sources.ex | 94 ++++++++++++++++++++++++++++++++ lib/hex/repo.ex | 13 +++++ lib/hex/state.ex | 7 +++ lib/mix/tasks/hex.config.ex | 9 +++ test/hex/policy/sources_test.exs | 55 +++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 lib/hex/policy/sources.ex create mode 100644 test/hex/policy/sources_test.exs diff --git a/lib/hex/policy/sources.ex b/lib/hex/policy/sources.ex new file mode 100644 index 00000000..281415e3 --- /dev/null +++ b/lib/hex/policy/sources.ex @@ -0,0 +1,94 @@ +defmodule Hex.Policy.Sources do + @moduledoc false + + @type ref :: {repo :: String.t(), name :: String.t()} + + @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`. + """ + @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 -> + {: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 + {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/repo.ex b/lib/hex/repo.ex index 76326ae1..ab448d95 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -228,6 +228,19 @@ 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` raises if + `repo_organization` is unset. + """ + def get_policy(repo, name) do + repo_config = get_repo(repo) + config = build_hex_core_config(repo_config, repo) + :mix_hex_repo.get_policy(config, name) + 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..af102b51 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -142,6 +142,13 @@ defmodule Hex.State do default: [], skip_env_if_empty: true, fun: {Hex.Cooldown, :parse_exclude_repos} + }, + policy: %{ + env: ["HEX_POLICY"], + config: [:policy], + default: [], + skip_env_if_empty: true, + fun: {Hex.Policy.Sources, :parse_config} } } diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index 164a2432..a7fc275b 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -75,6 +75,15 @@ defmodule Mix.Tasks.Hex.Config do consume them without cooldown delay. Can be overridden by setting the environment variable `HEX_COOLDOWN_EXCLUDE_REPOS` to a comma-separated list (Default: `[]`) + * `policy` - One or more policy references this client should honor at + resolution time. Accepts a single `[repo: "org", name: "policy-name"]` + tuple or a list of them. Composes with `HEX_POLICY` and any policy + configured in `~/.hex/hex.config` (intersection — no source can + subtract another). See `mix hex.policy` for a summary of the active + set. + * `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: diff --git a/test/hex/policy/sources_test.exs b/test/hex/policy/sources_test.exs new file mode 100644 index 00000000..79b60255 --- /dev/null +++ b/test/hex/policy/sources_test.exs @@ -0,0 +1,55 @@ +defmodule Hex.Policy.SourcesTest do + use HexTest.Case + 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 "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 +end From a789752775b3d80e97764a8d1f78b05c2939a882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:00:33 +0200 Subject: [PATCH 03/25] Add Hex.Policy.Loader with per-policy cache Parallel HTTP fetch via Hex.Repo.get_policy, signature-verified through the vendored mix_hex_repo. Decoded policies are stored on disk as erlang terms with an ETag sidecar; on fetch failure the last-known-good copy is loaded with a stale warning. Caches older than 30 days hard-fail for that specific policy with a clear error. Hex.Repo.get_policy/2 now derives repo_organization from the "hexpm:" key and strips the /repos/ URL suffix so the vendored hex_repo can build the right policy URL and verify the payload's repository field. Adds two computed Hex.State slots, :policies and :policy_filtered_versions, as stash points for later phases. --- lib/hex/policy/loader.ex | 126 ++++++++++++++++++++++++++++++++ lib/hex/repo.ex | 13 ++++ lib/hex/state.ex | 4 +- test/hex/policy/loader_test.exs | 94 ++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 lib/hex/policy/loader.ex create mode 100644 test/hex/policy/loader_test.exs diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex new file mode 100644 index 00000000..7e91d292 --- /dev/null +++ b/lib/hex/policy/loader.ex @@ -0,0 +1,126 @@ +defmodule Hex.Policy.Loader do + @moduledoc false + + alias Hex.Policy.Sources + + @max_age_days 30 + @cache_subdir "policies" + + @type ref :: Sources.ref() + @type policy :: map() + @type error_reason :: + {:stale_cache, ref(), pos_integer()} + | {:fetch, ref(), term()} + + @doc """ + Fetches every policy in `refs` in parallel. + + Returns `{:ok, %{ref => policy}}` on success or `{:error, reason}` + if any policy fails to load AND has no usable cache within the + staleness cap. + """ + @spec fetch_many([ref()]) :: {:ok, %{ref() => policy()}} | {:error, error_reason()} + def fetch_many([]), do: {:ok, %{}} + + def fetch_many(refs) do + refs + |> Enum.uniq() + |> Task.async_stream(&fetch_one/1, max_concurrency: 5, timeout: 30_000) + |> Enum.reduce_while({:ok, %{}}, fn + {:ok, {:ok, ref, policy}}, {:ok, acc} -> + {:cont, {:ok, Map.put(acc, ref, policy)}} + + {:ok, {:error, _ref, _reason} = error}, _acc -> + {:halt, {:error, normalize_error(error)}} + + {:exit, reason}, _acc -> + {:halt, {:error, {:fetch, :unknown, reason}}} + end) + end + + defp normalize_error({:error, ref, {:stale_cache, days}}), + do: {:stale_cache, ref, days} + + defp normalize_error({:error, ref, reason}), + do: {:fetch, ref, reason} + + defp fetch_one(ref) do + {repo, name} = ref + cache = read_cache(ref) + + case Hex.Repo.get_policy(repo, name) do + {:ok, {200, _headers, policy_map}} when is_map(policy_map) -> + write_cache(ref, policy_map, "") + {:ok, ref, policy_map} + + {:ok, {304, _headers, _}} when cache != nil -> + {:ok, ref, cache.decoded} + + {:ok, {status, _, _}} when status >= 400 -> + fallback(ref, cache, {:bad_status, status}) + + {:error, reason} -> + fallback(ref, cache, reason) + end + end + + defp fallback(ref, nil, reason), do: {:error, ref, reason} + + defp fallback(ref, cache, reason) do + age = days_old(cache) + + if age > @max_age_days do + {:error, ref, {:stale_cache, age}} + else + warn_stale(ref, age, reason) + {:ok, ref, cache.decoded} + end + end + + defp cache_path(ref) do + {repo, name} = ref + Path.join([cache_root(), @cache_subdir, repo, "#{name}.policy.term"]) + end + + defp etag_path(ref), do: cache_path(ref) <> ".etag" + + defp cache_root() do + Hex.State.fetch!(:cache_home) + end + + defp read_cache(ref) do + path = cache_path(ref) + + if File.exists?(path) do + %{ + decoded: :erlang.binary_to_term(File.read!(path)), + etag: File.read!(etag_path(ref)) + } + end + end + + defp write_cache(ref, decoded_map, etag) do + path = cache_path(ref) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, :erlang.term_to_binary(decoded_map)) + File.write!(etag_path(ref), etag || "") + :ok + end + + defp warn_stale(ref, age, _reason) do + {repo, name} = ref + + Hex.Shell.warn( + "Policy #{inspect(name)} of #{inspect(repo)} is from cache " <> + "(#{age} days old) — could not refresh" + ) + end + + defp days_old(%{decoded: decoded}) do + seconds_ago = System.system_time(:second) - Map.fetch!(decoded, :published_at) + max(0, div(seconds_ago, 86_400)) + end + + @doc false + def write_cache_for_test(ref, decoded_map, etag), do: write_cache(ref, decoded_map, etag) +end diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index ab448d95..c2a93972 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -237,10 +237,23 @@ defmodule Hex.Repo do """ def get_policy(repo, name) do repo_config = get_repo(repo) + organization = repo_organization(repo) config = build_hex_core_config(repo_config, repo) + config = put_repo_organization(config, repo_config, organization) :mix_hex_repo.get_policy(config, name) end + defp repo_organization("hexpm:" <> organization), do: organization + defp repo_organization(_), do: nil + + defp put_repo_organization(config, _repo_config, nil), do: config + + defp put_repo_organization(config, repo_config, organization) do + suffix = "/repos/#{organization}" + base_url = String.replace_suffix(repo_config.url, suffix, "") + %{config | repo_url: base_url, repo_organization: organization} + 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 af102b51..6f9cd05e 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -182,7 +182,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 diff --git a/test/hex/policy/loader_test.exs b/test/hex/policy/loader_test.exs new file mode 100644 index 00000000..911f4651 --- /dev/null +++ b/test/hex/policy/loader_test.exs @@ -0,0 +1,94 @@ +defmodule Hex.Policy.LoaderTest do + use HexTest.Case, async: false + + alias Hex.Policy.Loader + + 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 + }) + + 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, + published_at: System.system_time(:second) + } + + signed_policy(Map.merge(base, Map.new(extra))) + end + + test "fetches and decodes a policy", %{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("policy_loader_fetch", fn -> + Hex.State.put(:cache_home, File.cwd!()) + + assert {:ok, policies} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) + policy = Map.fetch!(policies, {"hexpm:myorg", "strict-prod"}) + assert policy.name == "strict-prod" + assert policy.advisory_min_severity == 3 + end) + end + + test "uses last-known-good cache on network failure", %{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("policy_loader_cache_fallback", fn -> + Hex.State.put(:cache_home, File.cwd!()) + {:ok, _} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) + + Bypass.down(bypass) + assert {:ok, policies} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) + assert Map.fetch!(policies, {"hexpm:myorg", "strict-prod"}).name == "strict-prod" + end) + end + + test "hard-fails when cache exceeds 30-day staleness cap", %{bypass: bypass} do + Bypass.down(bypass) + + in_tmp("policy_loader_stale", fn -> + Hex.State.put(:cache_home, File.cwd!()) + ref = {"hexpm:myorg", "strict-prod"} + stale_at = System.system_time(:second) - 31 * 86_400 + + stale_payload = %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC, + published_at: stale_at + } + + :ok = Loader.write_cache_for_test(ref, stale_payload, "\"old\"") + + assert {:error, {:stale_cache, ^ref, days}} = Loader.fetch_many([ref]) + assert days >= 30 + end) + end +end From 2d777d6cb484c1c8731d7ad103ce4232e1e6651a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:04:30 +0200 Subject: [PATCH 04/25] Add per-policy classify and strictest-wins cooldown Hex.Policy.Filter.classify/3 evaluates a single policy against a release (advisory severity threshold + retirement reason set); classify_set/3 ANDs across the active set and records every blocker for diagnostics. Hex.Policy.Cooldown.strictest/2 combines local and policy cooldown contributions and returns the longest duration string. source/2 identifies the contributor (`:local` or `{repo, name}`). --- lib/hex/policy/cooldown.ex | 49 +++++++++++++ lib/hex/policy/filter.ex | 75 +++++++++++++++++++ test/hex/policy/cooldown_test.exs | 57 +++++++++++++++ test/hex/policy/filter_test.exs | 115 ++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 lib/hex/policy/cooldown.ex create mode 100644 lib/hex/policy/filter.ex create mode 100644 test/hex/policy/cooldown_test.exs create mode 100644 test/hex/policy/filter_test.exs diff --git a/lib/hex/policy/cooldown.ex b/lib/hex/policy/cooldown.ex new file mode 100644 index 00000000..15a03d5b --- /dev/null +++ b/lib/hex/policy/cooldown.ex @@ -0,0 +1,49 @@ +defmodule Hex.Policy.Cooldown do + @moduledoc false + + alias Hex.Cooldown + + @doc """ + Returns the strictest (longest) cooldown duration string across the + local config and the active policy set. + """ + @spec strictest(String.t() | nil, [map()]) :: String.t() + def strictest(local, policies) do + candidates = [{:local, normalize(local), to_seconds(local)} | policy_durations(policies)] + {_tag, duration, _seconds} = Enum.max_by(candidates, &elem(&1, 2)) + duration + end + + @doc """ + Returns the source of the strictest cooldown — `:local` or + `{repo, name}` of a policy. + """ + @spec source(String.t() | nil, [map()]) :: :local | {String.t(), String.t()} + def source(local, policies) do + candidates = [{:local, normalize(local), to_seconds(local)} | policy_durations(policies)] + {tag, _duration, _seconds} = Enum.max_by(candidates, &elem(&1, 2)) + tag + end + + defp policy_durations(policies) do + for p <- policies, + cd = Map.get(p, :cooldown), + cd not in [nil, ""] do + {{Map.fetch!(p, :repository), Map.fetch!(p, :name)}, cd, to_seconds(cd)} + end + end + + defp to_seconds(nil), do: 0 + defp to_seconds(""), do: 0 + + defp to_seconds(s) when is_binary(s) do + case Cooldown.duration_to_seconds(s) do + {:ok, n} -> n + :error -> 0 + end + end + + defp normalize(nil), do: "0d" + defp normalize(""), do: "0d" + defp normalize(s), do: s +end diff --git a/lib/hex/policy/filter.ex b/lib/hex/policy/filter.ex new file mode 100644 index 00000000..fb3b3466 --- /dev/null +++ b/lib/hex/policy/filter.ex @@ -0,0 +1,75 @@ +defmodule Hex.Policy.Filter do + @moduledoc false + + @type policy :: map() + @type release :: map() + @type reason :: {:advisory, non_neg_integer()} | {:retirement, non_neg_integer()} + @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 + + defp add_advisory(reasons, %{advisory_min_severity: threshold}, release) + when is_integer(threshold) do + advisories = Map.get(release, :advisories, []) + + if Enum.any?(advisories, fn a -> Map.get(a, :severity, 0) >= threshold end) do + [{:advisory, threshold} | 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: r} -> + r_int = retired_to_int(r) + if r_int in ret_reasons, do: [{:retirement, r_int} | reasons], else: reasons + + _ -> + reasons + end + end + + defp add_retirement(reasons, _policy, _release), do: reasons + + defp retired_to_int(:RETIRED_OTHER), do: 0 + defp retired_to_int(:RETIRED_INVALID), do: 1 + defp retired_to_int(:RETIRED_SECURITY), do: 2 + defp retired_to_int(:RETIRED_DEPRECATED), do: 3 + defp retired_to_int(:RETIRED_RENAMED), do: 4 + defp retired_to_int(int) when is_integer(int), do: int +end diff --git a/test/hex/policy/cooldown_test.exs b/test/hex/policy/cooldown_test.exs new file mode 100644 index 00000000..e14766e7 --- /dev/null +++ b/test/hex/policy/cooldown_test.exs @@ -0,0 +1,57 @@ +defmodule Hex.Policy.CooldownTest do + use HexTest.Case + alias Hex.Policy.Cooldown + + describe "strictest/2" do + test "returns the longer of local + each policy duration" do + assert "14d" == + Cooldown.strictest("7d", [ + %{repository: "myorg", name: "p", cooldown: "14d"}, + %{repository: "myorg", name: "q", cooldown: "3d"} + ]) + end + + test "ignores nil policy cooldowns" do + assert "7d" == + Cooldown.strictest("7d", [ + %{repository: "myorg", name: "p", cooldown: nil}, + %{repository: "myorg", name: "q"} + ]) + end + + test "uses policy when local is 0" do + assert "14d" == + Cooldown.strictest("0d", [%{repository: "myorg", name: "p", cooldown: "14d"}]) + end + + test "returns 0d when nothing is set" do + assert "0d" == + Cooldown.strictest("0d", [%{repository: "myorg", name: "p", cooldown: nil}]) + end + + test "handles nil/empty local" do + assert "14d" == + Cooldown.strictest(nil, [%{repository: "myorg", name: "p", cooldown: "14d"}]) + + assert "14d" == + Cooldown.strictest("", [%{repository: "myorg", name: "p", cooldown: "14d"}]) + end + end + + describe "source/2" do + test "names the contributor of the strictest value" do + pol1 = %{repository: "myorg", name: "strict-prod", cooldown: "14d"} + pol2 = %{repository: "acme", name: "baseline", cooldown: "7d"} + assert {"myorg", "strict-prod"} == Cooldown.source("3d", [pol1, pol2]) + end + + test "returns :local when local is strictest" do + assert :local == + Cooldown.source("30d", [%{repository: "myorg", name: "p", cooldown: "7d"}]) + end + + test "returns :local when nothing else contributes" do + assert :local == Cooldown.source("7d", []) + 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..7ba3ee15 --- /dev/null +++ b/test/hex/policy/filter_test.exs @@ -0,0 +1,115 @@ +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, + published_at: 1_700_000_000 + }, + 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: 3}]}) + assert {:blocked, reasons} = Filter.classify(p, r, []) + assert {:advisory, 3} in reasons + end + + test "allows when release advisory < threshold" do + p = policy(%{advisory_min_severity: 3}) + r = release(%{advisories: [%{severity: 1}]}) + 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: 4}]}) + assert :allowed == Filter.classify(p, r, []) + end + end + + describe "classify/3 — retirement rule" do + test "blocks when release retired with selected reason (integer)" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release(%{retired: %{reason: 2}}) + assert {:blocked, reasons} = Filter.classify(p, r, []) + assert {:retirement, 2} in reasons + end + + test "blocks when release retired with selected reason (atom)" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release(%{retired: %{reason: :RETIRED_SECURITY}}) + assert {:blocked, reasons} = Filter.classify(p, r, []) + assert {:retirement, 2} in reasons + end + + test "allows when reason not in set" do + p = policy(%{retirement_reasons: [2, 3]}) + r = release(%{retired: %{reason: 4}}) + 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: 2}}) + 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: 3}]}) + + 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: 3}]}) + + 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: 3}]}) + assert :allowed == Filter.classify_set([p1, p2], r) + end + + test "allows with no policies" do + assert :allowed == Filter.classify_set([], release()) + end + end +end From b74ba39a82675e97933e882409b54c17567b32c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:11:36 +0200 Subject: [PATCH 05/25] Wire policies into resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hex.Policy.load_all reads the configured refs, dedups, and fetches the active set with cache fallback. converge/2 stashes the result under Hex.State up-front; on load failure Mix.raise surfaces a clear error. setup_cooldown now combines the local cooldown with policy cooldowns via Hex.Policy.Cooldown.strictest/2 — the existing cooldown filter handles age-based rejection for both sources. The solver runs against Hex.Registry.Policy, which wraps Hex.Registry.Cooldown: per-candidate classification via Hex.Policy.Filter.classify_set/2, with denied candidates filtered out and diagnostics recorded under :policy_filtered_versions. --- lib/hex/cooldown.ex | 12 ++- lib/hex/policy.ex | 18 ++++ lib/hex/registry/policy.ex | 83 +++++++++++++++ lib/hex/remote_converger.ex | 29 +++++- test/hex/registry/policy_test.exs | 121 ++++++++++++++++++++++ test/hex/remote_converger_policy_test.exs | 15 +++ 6 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 lib/hex/policy.ex create mode 100644 lib/hex/registry/policy.ex create mode 100644 test/hex/registry/policy_test.exs create mode 100644 test/hex/remote_converger_policy_test.exs diff --git a/lib/hex/cooldown.ex b/lib/hex/cooldown.ex index 42bed05e..d673b1aa 100644 --- a/lib/hex/cooldown.ex +++ b/lib/hex/cooldown.ex @@ -89,8 +89,16 @@ defmodule Hex.Cooldown do 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..789fe27a --- /dev/null +++ b/lib/hex/policy.ex @@ -0,0 +1,18 @@ +defmodule Hex.Policy do + @moduledoc false + + alias Hex.Policy.{Sources, Loader} + + @doc """ + Reads the configured policy refs (deduped), fetches each policy, + and returns the active set as a `%{ref => policy}` map. + + Returns `{:error, reason}` if any policy fails to load AND has no + usable cache within the staleness cap. + """ + @spec load_all() :: {:ok, %{Sources.ref() => map()}} | {:error, term()} + def load_all() do + refs = Hex.State.fetch!(:policy) |> Sources.dedup() + Loader.fetch_many(refs) + end +end diff --git a/lib/hex/registry/policy.ex b/lib/hex/registry/policy.ex new file mode 100644 index 00000000..4dda96e5 --- /dev/null +++ b/lib/hex/registry/policy.ex @@ -0,0 +1,83 @@ +defmodule Hex.Registry.Policy do + @moduledoc false + + @behaviour Hex.Solver.Registry + + alias Hex.Registry.{Cooldown, Server} + 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 = build_release(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 build_release(repo, package, version) do + version_str = to_string(version) + + %{ + version: version_str, + advisories: normalize_advisories(Server.advisories(repo, package, version_str)), + retired: Server.retired(repo, package, version_str) + } + end + + defp normalize_advisories(nil), do: [] + + defp normalize_advisories(advisories) when is_list(advisories) do + Enum.map(advisories, fn advisory -> + Map.update(advisory, :severity, 0, &severity_to_int/1) + end) + end + + defp severity_to_int(:SEVERITY_NONE), do: 0 + defp severity_to_int(:SEVERITY_LOW), do: 1 + defp severity_to_int(:SEVERITY_MEDIUM), do: 2 + defp severity_to_int(:SEVERITY_HIGH), do: 3 + defp severity_to_int(:SEVERITY_CRITICAL), do: 4 + defp severity_to_int(int) when is_integer(int), do: int + defp severity_to_int(_), do: 0 + + 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/remote_converger.ex b/lib/hex/remote_converger.ex index 258fbc46..d10099d6 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(format_policy_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 @@ -88,7 +96,7 @@ defmodule Hex.RemoteConverger do solution = try do Hex.Solver.run( - Hex.Registry.Cooldown, + Hex.Registry.Policy, dependencies, locked, overridden, @@ -116,7 +124,10 @@ defmodule Hex.RemoteConverger do end defp setup_cooldown(old_lock, locked) do - cutoff = Hex.Cooldown.build_cutoff() + local = Hex.State.fetch!(:cooldown) + policies = Map.values(Hex.State.fetch!(:policies)) + effective = Hex.Policy.Cooldown.strictest(local, policies) + cutoff = Hex.Cooldown.build_cutoff(effective) Hex.State.put(:cooldown_cutoff, cutoff) bypass = build_cooldown_bypass(old_lock, locked, cutoff) @@ -124,6 +135,7 @@ defmodule Hex.RemoteConverger do Hex.State.put(:cooldown_locked_versions, build_cooldown_locked_versions(old_lock)) Hex.State.put(:cooldown_filtered_versions, []) + Hex.State.put(:policy_filtered_versions, []) end @doc false @@ -890,4 +902,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 format_policy_load_error({:stale_cache, {repo, name}, days}) do + "Policy #{inspect(name)} of #{inspect(repo)} cache is #{days} days old " <> + "(max 30) and the registry is unreachable." + end + + defp format_policy_load_error({:fetch, {repo, name}, reason}) do + "Failed to fetch policy #{inspect(name)} of #{inspect(repo)}: #{inspect(reason)}" + end + + defp format_policy_load_error(other) do + "Policy loading failed: #{inspect(other)}" + end end diff --git a/test/hex/registry/policy_test.exs b/test/hex/registry/policy_test.exs new file mode 100644 index 00000000..beef9fc3 --- /dev/null +++ b/test/hex/registry/policy_test.exs @@ -0,0 +1,121 @@ +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, + published_at: System.system_time(:second), + 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, 3}}] = 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, + published_at: System.system_time(:second), + 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, 2}}] = 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, + published_at: System.system_time(:second), + 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/remote_converger_policy_test.exs b/test/hex/remote_converger_policy_test.exs new file mode 100644 index 00000000..e337bfd6 --- /dev/null +++ b/test/hex/remote_converger_policy_test.exs @@ -0,0 +1,15 @@ +defmodule Hex.RemoteConvergerPolicyTest do + use HexTest.Case + + test "with no policies configured, Hex.Policy.load_all returns empty" do + Hex.State.put(:policy, []) + assert {:ok, %{}} = Hex.Policy.load_all() + end + + test "Hex.Policy.Cooldown.strictest folds local + policies" do + assert "14d" == + Hex.Policy.Cooldown.strictest("7d", [ + %{repository: "myorg", name: "p", cooldown: "14d"} + ]) + end +end From 96d11c91f24e5e3e3e1ddd9dac36e38aaaee0b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:20:05 +0200 Subject: [PATCH 06/25] Add policy diagnostics, pre-flight check, and summary Hex.Policy.Diagnostics owns rendering: resolution_summary/3, preflight_error/4, failure_note/1, and format_load_error/1. RemoteConverger.policy_preflight! runs after verify_input and before the solver, scanning every direct request whose constraint has no eligible version under the active policy set and raising a focused preflight_error. After every resolution that loaded a policy, print_policy_summary prints the active set, the effective cooldown, and per-policy hidden counts. On solver failure, a Note: block is appended listing the responsible policies for transitive deps whose candidate set was emptied. normalize_advisories / severity_atom_to_int were lifted from Hex.Registry.Policy into Hex.Policy.Filter so both the registry wrapper and the pre-flight share one canonical conversion. --- lib/hex/policy/diagnostics.ex | 181 ++++++++++++++++++++++ lib/hex/policy/filter.ex | 29 ++++ lib/hex/registry/policy.ex | 18 +-- lib/hex/remote_converger.ex | 95 +++++++++++- test/hex/policy/diagnostics_test.exs | 104 +++++++++++++ test/hex/remote_converger_policy_test.exs | 69 +++++++++ 6 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 lib/hex/policy/diagnostics.ex create mode 100644 test/hex/policy/diagnostics_test.exs diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex new file mode 100644 index 00000000..a6542a54 --- /dev/null +++ b/lib/hex/policy/diagnostics.ex @@ -0,0 +1,181 @@ +defmodule Hex.Policy.Diagnostics do + @moduledoc false + + alias Hex.Policy.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_cooldown, policies) do + "0d" -> + nil + + duration -> + source_str = + case Cooldown.source(local_cooldown, policies) 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 focused pre-flight error for a direct dep whose constraint + has no eligible version under the active policies. + + `package` is the package name, `requirement` the original Hex + requirement string, `version_blockers` a list of + `{version_string, [blocker]}` tuples. + """ + @spec preflight_error(String.t(), String.t(), [{String.t(), [map()]}], [map()]) :: + String.t() + def preflight_error(package, requirement, version_blockers, active_policies) do + blocked_lines = + Enum.map(version_blockers, fn {version, blockers} -> + attribution = blockers |> Enum.map(&format_blocker/1) |> Enum.join(", ") + " #{package} #{version} — #{attribution}" + end) + + active_lines = + Enum.map(active_policies, fn p -> + " * #{p.repository}/#{p.name}" + end) + + """ + ** (Mix) Hex dependency resolution failed + + All versions of "#{package}" matching "#{requirement}" are blocked by active policies: + + #{Enum.join(blocked_lines, "\n")} + + Active policies: + #{Enum.join(active_lines, "\n")} + + To proceed: + * Update one or more policies to allow the version you need + * Adjust your version requirement to a permitted range + * Remove the offending source (e.g. `policy:` from mix.exs or `HEX_POLICY` env var) + """ + end + + @doc """ + Renders a Note: block to append to a solver failure when active + policies emptied a transitive dep's candidate set. + + 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.Loader` load error for `Mix.raise/1`. + """ + @spec format_load_error(term()) :: String.t() + def format_load_error({:stale_cache, {repo, name}, days}) do + "Policy #{inspect(name)} of #{inspect(repo)} cache is #{days} days old " <> + "(max 30) and the registry is unreachable." + end + + def format_load_error({:fetch, {repo, name}, reason}) do + "Failed to fetch policy #{inspect(name)} of #{inspect(repo)}: #{inspect(reason)}" + 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 ≥ #{severity_label(sev)})" + end + + defp format_blocker(%{policy: p, reason: {:retirement, r}}) do + "#{p.repository}/#{p.name} (retirement: #{retirement_label(r)})" + end + + defp format_blocker(%{policy: p, reason: other}) do + "#{p.repository}/#{p.name} (#{inspect(other)})" + end + + defp severity_label(1), do: "low" + defp severity_label(2), do: "medium" + defp severity_label(3), do: "high" + defp severity_label(4), do: "critical" + defp severity_label(other), do: to_string(other) + + defp retirement_label(0), do: "other" + defp retirement_label(1), do: "invalid" + defp retirement_label(2), do: "security" + defp retirement_label(3), do: "deprecated" + defp retirement_label(4), do: "renamed" + defp retirement_label(other), do: to_string(other) +end diff --git a/lib/hex/policy/filter.ex b/lib/hex/policy/filter.ex index fb3b3466..3b34768f 100644 --- a/lib/hex/policy/filter.ex +++ b/lib/hex/policy/filter.ex @@ -72,4 +72,33 @@ defmodule Hex.Policy.Filter do defp retired_to_int(:RETIRED_DEPRECATED), do: 3 defp retired_to_int(:RETIRED_RENAMED), do: 4 defp retired_to_int(int) when is_integer(int), do: int + + @doc """ + Normalizes a list of advisories to use integer severities. + + Accepts `nil` (treated as empty list). Each advisory's `:severity` is + converted from the hex_core atom representation + (`:SEVERITY_NONE`..`:SEVERITY_CRITICAL`) into an integer in `0..4`. + Integer severities are passed through; unknown values become `0`. + """ + @spec normalize_advisories(nil | [map()]) :: [map()] + def normalize_advisories(nil), do: [] + + def normalize_advisories(advisories) when is_list(advisories) do + Enum.map(advisories, fn advisory -> + Map.update(advisory, :severity, 0, &severity_to_int/1) + end) + end + + @doc """ + Converts an advisory severity (atom or integer) to its integer form. + """ + @spec severity_to_int(atom() | integer()) :: 0..4 + def severity_to_int(:SEVERITY_NONE), do: 0 + def severity_to_int(:SEVERITY_LOW), do: 1 + def severity_to_int(:SEVERITY_MEDIUM), do: 2 + def severity_to_int(:SEVERITY_HIGH), do: 3 + def severity_to_int(:SEVERITY_CRITICAL), do: 4 + def severity_to_int(int) when is_integer(int), do: int + def severity_to_int(_), do: 0 end diff --git a/lib/hex/registry/policy.ex b/lib/hex/registry/policy.ex index 4dda96e5..59b451a8 100644 --- a/lib/hex/registry/policy.ex +++ b/lib/hex/registry/policy.ex @@ -49,27 +49,11 @@ defmodule Hex.Registry.Policy do %{ version: version_str, - advisories: normalize_advisories(Server.advisories(repo, package, version_str)), + advisories: Filter.normalize_advisories(Server.advisories(repo, package, version_str)), retired: Server.retired(repo, package, version_str) } end - defp normalize_advisories(nil), do: [] - - defp normalize_advisories(advisories) when is_list(advisories) do - Enum.map(advisories, fn advisory -> - Map.update(advisory, :severity, 0, &severity_to_int/1) - end) - end - - defp severity_to_int(:SEVERITY_NONE), do: 0 - defp severity_to_int(:SEVERITY_LOW), do: 1 - defp severity_to_int(:SEVERITY_MEDIUM), do: 2 - defp severity_to_int(:SEVERITY_HIGH), do: 3 - defp severity_to_int(:SEVERITY_CRITICAL), do: 4 - defp severity_to_int(int) when is_integer(int), do: int - defp severity_to_int(_), do: 0 - defp record_block(repo, package, version, blockers) do entry = %{ repo: repo || "hexpm", diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index d10099d6..f5032506 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -46,7 +46,7 @@ defmodule Hex.RemoteConverger do Hex.State.put(:policies, policies) {:error, reason} -> - Mix.raise(format_policy_load_error(reason)) + Mix.raise(Hex.Policy.Diagnostics.format_load_error(reason)) end # We cannot use given lock here, because all deps that are being @@ -78,6 +78,8 @@ defmodule Hex.RemoteConverger do verify_deps(deps, Hex.Mix.top_level(deps)) verify_input(requests, locked) + policy_preflight!(requests) + Hex.Shell.info("Resolving Hex dependencies...") run_solver(lock, old_lock, requests, locked, overridden) end @@ -114,11 +116,18 @@ 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) + + if note = Hex.Policy.Diagnostics.failure_note(Hex.State.fetch!(:policy_filtered_versions)) do + Hex.Shell.info("\n" <> note) + end + print_cooldown_summary() + print_policy_summary() Mix.raise("Hex dependency resolution failed") end end @@ -903,16 +912,86 @@ defmodule Hex.RemoteConverger do Map.get(repo_config, :trusted, true) && !repo_config.auth_key end - defp format_policy_load_error({:stale_cache, {repo, name}, days}) do - "Policy #{inspect(name)} of #{inspect(repo)} cache is #{days} days old " <> - "(max 30) and the registry is unreachable." + 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 - defp format_policy_load_error({:fetch, {repo, name}, reason}) do - "Failed to fetch policy #{inspect(name)} of #{inspect(repo)}: #{inspect(reason)}" + defp policy_preflight!(requests) do + policies = Map.values(Hex.State.fetch!(:policies)) + + if policies != [] do + Enum.each(requests, fn request -> + check_request_against_policies(request, policies) + end) + end end - defp format_policy_load_error(other) do - "Policy loading failed: #{inspect(other)}" + defp check_request_against_policies(%{repo: repo, name: name, requirement: req}, policies) + when is_binary(name) do + case Registry.versions(repo, name) do + {:ok, all_versions} -> + matching = + if req do + {:ok, parsed_req} = Version.parse_requirement(req) + Enum.filter(all_versions, fn v -> Version.match?(v, parsed_req) end) + else + all_versions + end + + if matching != [] do + per_version_blockers = + for v <- matching do + release = build_release_for_preflight(repo, name, v) + + case Hex.Policy.Filter.classify_set(policies, release) do + :allowed -> {v, []} + {:blocked, blockers} -> {v, blockers} + end + end + + any_allowed? = Enum.any?(per_version_blockers, fn {_, b} -> b == [] end) + + if not any_allowed? do + version_blockers = + Enum.map(per_version_blockers, fn {v, blockers} -> + {to_string(v), blockers} + end) + + Mix.raise( + Hex.Policy.Diagnostics.preflight_error( + name, + req || "*", + version_blockers, + policies + ) + ) + end + end + + :error -> + :ok + end + end + + defp check_request_against_policies(_, _), do: :ok + + defp build_release_for_preflight(repo, package, version) do + version_str = to_string(version) + + %{ + version: version_str, + advisories: + Hex.Policy.Filter.normalize_advisories(Registry.advisories(repo, package, version_str)), + retired: Registry.retired(repo, package, version_str) + } end end diff --git a/test/hex/policy/diagnostics_test.exs b/test/hex/policy/diagnostics_test.exs new file mode 100644 index 00000000..e34c280f --- /dev/null +++ b/test/hex/policy/diagnostics_test.exs @@ -0,0 +1,104 @@ +defmodule Hex.Policy.DiagnosticsTest do + use HexTest.Case + alias Hex.Policy.Diagnostics + + defp policy(opts) do + Map.merge( + %{repository: "myorg", visibility: :VISIBILITY_PUBLIC, published_at: 0}, + 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, 3}}] + } + ] + + 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 "preflight_error/4" do + test "renders the expected block" do + pol = policy(name: "strict-prod") + + out = + Diagnostics.preflight_error( + "phoenix", + "~> 1.7", + [ + {"1.7.18", [%{policy: pol, reason: {:advisory, 3}}]}, + {"1.7.19", [%{policy: pol, reason: {:retirement, 2}}]} + ], + [pol] + ) + + assert out =~ "Hex dependency resolution failed" + assert out =~ ~s|All versions of "phoenix" matching "~> 1.7"| + assert out =~ "myorg/strict-prod (advisory ≥ high)" + assert out =~ "myorg/strict-prod (retirement: security)" + assert out =~ "Active policies:\n * myorg/strict-prod" + 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, 2}}] + }, + %{ + repo: "hexpm", + package: "decimal", + version: "2.0.1", + blockers: [%{policy: pol, reason: {:advisory, 3}}] + } + ]) + + assert out =~ "Note: active policies hide 2 versions of \"decimal\"" + assert out =~ "decimal 2.0.0" + assert out =~ "decimal 2.0.1" + end + end + + describe "format_load_error/1" do + test "stale_cache" do + assert Diagnostics.format_load_error({:stale_cache, {"hexpm:myorg", "strict-prod"}, 31}) =~ + "cache is 31 days old" + end + + test "fetch error" do + assert Diagnostics.format_load_error({:fetch, {"hexpm:myorg", "strict-prod"}, :timeout}) =~ + "Failed to fetch policy" + end + end +end diff --git a/test/hex/remote_converger_policy_test.exs b/test/hex/remote_converger_policy_test.exs index e337bfd6..012b91cc 100644 --- a/test/hex/remote_converger_policy_test.exs +++ b/test/hex/remote_converger_policy_test.exs @@ -1,6 +1,8 @@ defmodule Hex.RemoteConvergerPolicyTest do use HexTest.Case + alias Hex.Policy.{Diagnostics, Filter} + test "with no policies configured, Hex.Policy.load_all returns empty" do Hex.State.put(:policy, []) assert {:ok, %{}} = Hex.Policy.load_all() @@ -12,4 +14,71 @@ defmodule Hex.RemoteConvergerPolicyTest do %{repository: "myorg", name: "p", cooldown: "14d"} ]) end + + describe "pre-flight diagnostic shape" do + # Focused diagnostic test (not a full converge integration). The full + # converge run requires substantial registry-fixture wiring; the + # pre-flight scan's two moving parts — `Hex.Policy.Filter.classify_set` + # producing blockers and `Hex.Policy.Diagnostics.preflight_error` + # rendering them — are exercised here in isolation. Rendering details + # already covered by `Hex.Policy.DiagnosticsTest`. + + test "Filter.classify_set returns blocker shape that Diagnostics.preflight_error renders" do + policies = [ + %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC, + published_at: 0, + advisory_min_severity: 3 + } + ] + + # Two unsafe versions of phoenix: both flagged by the policy. + releases = [ + {"1.7.18", + %{ + version: "1.7.18", + advisories: Filter.normalize_advisories([%{severity: :SEVERITY_HIGH}]), + retired: nil + }}, + {"1.7.19", + %{ + version: "1.7.19", + advisories: Filter.normalize_advisories([%{severity: :SEVERITY_CRITICAL}]), + retired: nil + }} + ] + + version_blockers = + Enum.map(releases, fn {v, release} -> + {:blocked, blockers} = Filter.classify_set(policies, release) + {v, blockers} + end) + + # The result is what `policy_preflight!` hands to Diagnostics. + out = Diagnostics.preflight_error("phoenix", "~> 1.7", version_blockers, policies) + + assert out =~ "Hex dependency resolution failed" + assert out =~ ~s|All versions of "phoenix" matching "~> 1.7"| + assert out =~ "phoenix 1.7.18" + assert out =~ "phoenix 1.7.19" + assert out =~ "myorg/strict-prod (advisory ≥ high)" + end + + test "Filter.classify_set returns :allowed when any version slips through" do + policies = [ + %{ + repository: "myorg", + name: "strict-prod", + visibility: :VISIBILITY_PUBLIC, + published_at: 0, + advisory_min_severity: 3 + } + ] + + clean_release = %{version: "1.7.20", advisories: [], retired: nil} + assert :allowed = Filter.classify_set(policies, clean_release) + end + end end From 53e18888acf812dae0c347c54d61d915a0507413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:26:50 +0200 Subject: [PATCH 07/25] Add mix hex.policy task `mix hex.policy show` (default) prints the active policy set with per-policy visibility, cooldown, advisory rule, retirement rule, and the effective cooldown. `mix hex.policy why PACKAGE` walks every version of PACKAGE in the registry, classifies each against the active policies via Hex.Policy.Filter, and prints a per-version status table. --- lib/mix/tasks/hex.policy.ex | 184 +++++++++++++++++++++++++++++ test/mix/tasks/hex.policy_test.exs | 56 +++++++++ 2 files changed, 240 insertions(+) create mode 100644 lib/mix/tasks/hex.policy.ex create mode 100644 test/mix/tasks/hex.policy_test.exs diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex new file mode 100644 index 00000000..68342edf --- /dev/null +++ b/lib/mix/tasks/hex.policy.ex @@ -0,0 +1,184 @@ +defmodule Mix.Tasks.Hex.Policy do + use Mix.Task + + alias Hex.Policy.{Cooldown, Filter} + alias Hex.Policy.Diagnostics + + @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. + """ + + @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.State.fetch!(:policies) do + map when map_size(map) > 0 -> + render_show(map) + + _ -> + if Hex.State.fetch!(:policy) == [] do + Hex.Shell.info("No active policies configured.") + else + case Hex.Policy.load_all() do + {:ok, policies_map} -> + Hex.State.put(:policies, policies_map) + render_show(policies_map) + + {:error, reason} -> + Mix.raise(Diagnostics.format_load_error(reason)) + end + end + 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}") + + advisory = Map.get(p, :advisory_min_severity) + Hex.Shell.info(" Advisory rule: #{advisory_label(advisory)}") + + retirement = Map.get(p, :retirement_reasons) || [] + Hex.Shell.info(" Retirement rule: #{retirement_label(retirement)}") + + Hex.Shell.info("") + end) + + local = Hex.State.fetch!(:cooldown) + + case Cooldown.strictest(local, policies) do + "0d" -> + :ok + + duration -> + source_str = + case Cooldown.source(local, policies) do + :local -> "local" + {repo, name} -> "#{repo}/#{name}" + end + + Hex.Shell.info("Effective cooldown: #{duration} (from #{source_str})") + end + end + + defp why(package) do + Hex.Registry.Server.open() + Hex.Registry.Server.prefetch([{"hexpm", package}]) + + case Hex.Registry.Server.versions("hexpm", package) do + {:ok, versions} -> + render_why(package, versions) + + :error -> + Mix.raise("No package with name #{package} in registry") + end + end + + defp render_why(package, versions) do + policies = Map.values(Hex.State.fetch!(:policies)) + + if policies == [] do + Hex.Shell.info("No active policies configured.") + else + Hex.Shell.info("Versions of #{inspect(package)} (#{length(versions)}):") + Hex.Shell.info("") + + Enum.each(versions, fn v -> + version = to_string(v) + + release = %{ + version: version, + advisories: + Filter.normalize_advisories( + Hex.Registry.Server.advisories("hexpm", package, version) || [] + ), + retired: Hex.Registry.Server.retired("hexpm", package, version) + } + + case Filter.classify_set(policies, release) do + :allowed -> + Hex.Shell.info(" #{version} ALLOWED") + + {:blocked, blockers} -> + blocker_text = + blockers + |> Enum.map(fn b -> "#{b.policy.repository}/#{b.policy.name}" end) + |> Enum.uniq() + |> Enum.join(", ") + + Hex.Shell.info(" #{version} blocked by #{blocker_text}") + end + end) + end + end + + 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(1), do: "block ≥ LOW" + defp advisory_label(2), do: "block ≥ MEDIUM" + defp advisory_label(3), do: "block ≥ HIGH" + defp advisory_label(4), do: "block ≥ CRITICAL" + + defp retirement_label([]), do: "(disabled)" + defp retirement_label(nil), do: "(disabled)" + + defp retirement_label(reasons) when is_list(reasons) do + reasons + |> Enum.map(&reason_name/1) + |> Enum.join(", ") + end + + defp reason_name(0), do: "OTHER" + defp reason_name(1), do: "INVALID" + defp reason_name(2), do: "SECURITY" + defp reason_name(3), do: "DEPRECATED" + defp reason_name(4), do: "RENAMED" + defp reason_name(other), do: to_string(other) +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..27c0c446 --- /dev/null +++ b/test/mix/tasks/hex.policy_test.exs @@ -0,0 +1,56 @@ +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, + published_at: System.system_time(:second), + 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 + + # 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 From c14b1e090e694cd0252796c243fd49e2eebeb7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 21 May 2026 13:35:34 +0200 Subject: [PATCH 08/25] Address Plan 3 closing-review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mix hex.policy why` now loads policies cold (same fallback as `show`) and accepts a `REPO/PACKAGE` form so private-repo packages can be inspected. Per-version blocker output names the reason (advisory severity / retirement reason) instead of collapsing to just the policy name. Loader.read_cache tolerates a missing .etag sidecar instead of crashing. Diagnostics.preflight_error no longer hardcodes a `** (Mix) ` prefix — that's Mix.raise/1's job — so the rendered error doesn't double up. --- lib/hex/policy/diagnostics.ex | 2 +- lib/hex/policy/loader.ex | 8 +- lib/mix/tasks/hex.policy.ex | 162 +++++++++++++++++++++------------- 3 files changed, 111 insertions(+), 61 deletions(-) diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex index a6542a54..90b4293d 100644 --- a/lib/hex/policy/diagnostics.ex +++ b/lib/hex/policy/diagnostics.ex @@ -96,7 +96,7 @@ defmodule Hex.Policy.Diagnostics do end) """ - ** (Mix) Hex dependency resolution failed + Hex dependency resolution failed All versions of "#{package}" matching "#{requirement}" are blocked by active policies: diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex index 7e91d292..8c981f88 100644 --- a/lib/hex/policy/loader.ex +++ b/lib/hex/policy/loader.ex @@ -92,9 +92,15 @@ defmodule Hex.Policy.Loader do path = cache_path(ref) if File.exists?(path) do + etag = + case File.read(etag_path(ref)) do + {:ok, contents} -> contents + _ -> "" + end + %{ decoded: :erlang.binary_to_term(File.read!(path)), - etag: File.read!(etag_path(ref)) + etag: etag } end end diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index 68342edf..08e0f0e0 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -48,22 +48,36 @@ defmodule Mix.Tasks.Hex.Policy do end defp show() do - case Hex.State.fetch!(:policies) do - map when map_size(map) > 0 -> - render_show(map) - - _ -> - if Hex.State.fetch!(:policy) == [] do - Hex.Shell.info("No active policies configured.") - else - case Hex.Policy.load_all() do - {:ok, policies_map} -> - Hex.State.put(:policies, policies_map) - render_show(policies_map) - - {:error, reason} -> - Mix.raise(Diagnostics.format_load_error(reason)) - end + case active_policies_or_load() 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 active_policies_or_load() do + loaded = Hex.State.fetch!(:policies) + + cond do + loaded != %{} -> + {:ok, loaded} + + Hex.State.fetch!(:policy) == [] -> + {:ok, %{}} + + true -> + case Hex.Policy.load_all() do + {:ok, policies_map} -> + Hex.State.put(:policies, policies_map) + {:ok, policies_map} + + {:error, _} = err -> + err end end end @@ -104,57 +118,87 @@ defmodule Mix.Tasks.Hex.Policy do end end - defp why(package) do - Hex.Registry.Server.open() - Hex.Registry.Server.prefetch([{"hexpm", package}]) + defp why(arg) do + {repo, package} = parse_package_arg(arg) - case Hex.Registry.Server.versions("hexpm", package) do - {:ok, versions} -> - render_why(package, versions) + case active_policies_or_load() do + {:ok, policies_map} when map_size(policies_map) > 0 -> + Hex.Registry.Server.open() + Hex.Registry.Server.prefetch([{repo, package}]) - :error -> - Mix.raise("No package with name #{package} in registry") - end - end + case Hex.Registry.Server.versions(repo, package) do + {:ok, versions} -> + render_why(repo, package, versions, Map.values(policies_map)) - defp render_why(package, versions) do - policies = Map.values(Hex.State.fetch!(:policies)) + :error -> + Mix.raise("No package with name #{package} in registry") + end - if policies == [] do - Hex.Shell.info("No active policies configured.") - else - Hex.Shell.info("Versions of #{inspect(package)} (#{length(versions)}):") - Hex.Shell.info("") + {:ok, _} -> + Hex.Shell.info("No active policies configured.") - Enum.each(versions, fn v -> - version = to_string(v) - - release = %{ - version: version, - advisories: - Filter.normalize_advisories( - Hex.Registry.Server.advisories("hexpm", package, version) || [] - ), - retired: Hex.Registry.Server.retired("hexpm", package, version) - } - - case Filter.classify_set(policies, release) do - :allowed -> - Hex.Shell.info(" #{version} ALLOWED") - - {:blocked, blockers} -> - blocker_text = - blockers - |> Enum.map(fn b -> "#{b.policy.repository}/#{b.policy.name}" end) - |> Enum.uniq() - |> Enum.join(", ") - - Hex.Shell.info(" #{version} blocked by #{blocker_text}") - end - end) + {: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] -> {"hexpm", package} + [repo, package] -> {repo, package} + end + end + + defp render_why(repo, package, versions, policies) do + Hex.Shell.info("Versions of #{inspect(package)} (#{length(versions)}):") + Hex.Shell.info("") + + Enum.each(versions, fn v -> + version = to_string(v) + + release = %{ + version: version, + advisories: + Filter.normalize_advisories( + Hex.Registry.Server.advisories(repo, package, version) || [] + ), + retired: Hex.Registry.Server.retired(repo, package, version) + } + + case Filter.classify_set(policies, release) do + :allowed -> + Hex.Shell.info(" #{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(", ") + + Hex.Shell.info(" #{version} blocked by #{blocker_text}") + end + end) + end + + defp format_reason({:advisory, sev}), do: "advisory ≥ #{severity_word(sev)}" + defp format_reason({:retirement, r}), do: "retirement: #{retirement_word(r)}" + + defp severity_word(1), do: "low" + defp severity_word(2), do: "medium" + defp severity_word(3), do: "high" + defp severity_word(4), do: "critical" + defp severity_word(n), do: to_string(n) + + defp retirement_word(0), do: "other" + defp retirement_word(1), do: "invalid" + defp retirement_word(2), do: "security" + defp retirement_word(3), do: "deprecated" + defp retirement_word(4), do: "renamed" + defp retirement_word(n), do: to_string(n) + defp visibility_label(:VISIBILITY_PUBLIC), do: "public" defp visibility_label(:VISIBILITY_PRIVATE), do: "private" defp visibility_label(other), do: to_string(other) From 54495a34c7686fdfded275c76ffe45dee2e6ce32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 01:12:08 +0200 Subject: [PATCH 09/25] Expand CLI moduledocs for cooldown and policy `mix help hex.policy` now shows the three opt-in mechanisms (mix.exs, HEX_POLICY, mix hex.config) with examples, and points at the new hex.pm docs page. The `mix hex.config` `policy` bullet clarifies the CLI form (string) versus the mix.exs richer keyword form, so users don't try to pass keyword lists through `mix hex.config KEY VALUE`. --- lib/mix/tasks/hex.config.ex | 21 +++++++++++++-------- lib/mix/tasks/hex.policy.ex | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index a7fc275b..35c2c7f8 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -68,19 +68,24 @@ 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: `[]`) - * `policy` - One or more policy references this client should honor at - resolution time. Accepts a single `[repo: "org", name: "policy-name"]` - tuple or a list of them. Composes with `HEX_POLICY` and any policy - configured in `~/.hex/hex.config` (intersection — no source can - subtract another). See `mix hex.policy` for a summary of the active - set. + 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. Composes with `HEX_POLICY` and any policy + set in `mix.exs` (intersection — 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`. diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index 08e0f0e0..e1fbbdbb 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -21,6 +21,27 @@ defmodule Mix.Tasks.Hex.Policy do * `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, + composed via AND (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 From f15f54cc91fe1ba33a8977d79acebc6c5a0202be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 17:13:41 +0200 Subject: [PATCH 10/25] Implement AND composition of policy config sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources.load_all/0 reads policies from mix.exs, HEX_POLICY, and ~/.hex/hex.config independently and unions+dedups. The :policy state slot is removed — Hex.Policy.Sources is now the source of truth, matching the documented intersection-across-sources behavior. Tests cover multi-source union explicitly. --- lib/hex/policy.ex | 14 +++-- lib/hex/policy/sources.ex | 37 +++++++++++++ lib/hex/state.ex | 7 --- lib/mix/tasks/hex.config.ex | 64 ++++++++++++++++++----- lib/mix/tasks/hex.policy.ex | 17 ++++-- test/hex/policy/sources_test.exs | 51 +++++++++++++++++- test/hex/remote_converger_policy_test.exs | 16 +++++- 7 files changed, 173 insertions(+), 33 deletions(-) diff --git a/lib/hex/policy.ex b/lib/hex/policy.ex index 789fe27a..b6d253a1 100644 --- a/lib/hex/policy.ex +++ b/lib/hex/policy.ex @@ -4,15 +4,19 @@ defmodule Hex.Policy do alias Hex.Policy.{Sources, Loader} @doc """ - Reads the configured policy refs (deduped), fetches each policy, - and returns the active set as a `%{ref => policy}` map. + Reads the configured policy refs from all sources (project, env, + global), unions them, fetches each policy, and returns the active + set as a `%{ref => policy}` map. Returns `{:error, reason}` if any policy fails to load AND has no - usable cache within the staleness cap. + usable cache within the staleness cap, or `{:error, + :invalid_policy_config}` if any source has a malformed value. """ @spec load_all() :: {:ok, %{Sources.ref() => map()}} | {:error, term()} def load_all() do - refs = Hex.State.fetch!(:policy) |> Sources.dedup() - Loader.fetch_many(refs) + case Sources.load_all() do + {:ok, refs} -> Loader.fetch_many(refs) + :error -> {:error, :invalid_policy_config} + end end end diff --git a/lib/hex/policy/sources.ex b/lib/hex/policy/sources.ex index 281415e3..5d6a34cc 100644 --- a/lib/hex/policy/sources.ex +++ b/lib/hex/policy/sources.ex @@ -3,6 +3,43 @@ defmodule Hex.Policy.Sources do @type ref :: {repo :: String.t(), name :: String.t()} + @doc """ + Reads policy refs from all three sources independently and unions them, + deduplicated. Order: project (`mix.exs`) first, then env (`HEX_POLICY`), + then global (`~/.hex/hex.config`). Dedup preserves first-seen order. + + Returns `{:ok, refs}` if every source parses cleanly, otherwise `:error`. + """ + @spec load_all() :: {:ok, [ref()]} | :error + def load_all() do + with {:ok, project} <- parse_config(read_project()), + {:ok, env} <- parse_config(read_env()), + {:ok, global} <- parse_config(read_global()) do + {:ok, dedup(project ++ env ++ global)} + else + _ -> :error + end + end + + defp read_project() do + Mix.Project.config() + |> Keyword.get(:hex, []) + |> Keyword.get(:policy, []) + end + + defp read_env() do + case System.get_env("HEX_POLICY") do + nil -> [] + "" -> [] + value -> value + end + end + + defp read_global() do + Hex.Config.read() + |> Keyword.get(:policy, []) + end + @doc """ Parses a `policy` configuration value into a list of `{repo, name}` refs. diff --git a/lib/hex/state.ex b/lib/hex/state.ex index 6f9cd05e..66ed1e09 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -142,13 +142,6 @@ defmodule Hex.State do default: [], skip_env_if_empty: true, fun: {Hex.Cooldown, :parse_exclude_repos} - }, - policy: %{ - env: ["HEX_POLICY"], - config: [:policy], - default: [], - skip_env_if_empty: true, - fun: {Hex.Policy.Sources, :parse_config} } } diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index 35c2c7f8..f43f2ed8 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -82,8 +82,10 @@ defmodule Mix.Tasks.Hex.Config do `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. Composes with `HEX_POLICY` and any policy - set in `mix.exs` (intersection — no source can subtract another). + 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 @@ -191,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}} -> @@ -236,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 index e1fbbdbb..9cc9ba4a 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -1,7 +1,7 @@ defmodule Mix.Tasks.Hex.Policy do use Mix.Task - alias Hex.Policy.{Cooldown, Filter} + alias Hex.Policy.{Cooldown, Filter, Sources} alias Hex.Policy.Diagnostics @shortdoc "Inspects active Hex dependency policies" @@ -24,8 +24,10 @@ defmodule Mix.Tasks.Hex.Policy do ## Opting in - Three configuration sources contribute to the active policy set, - composed via AND (no source can subtract from another): + 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: @@ -88,7 +90,7 @@ defmodule Mix.Tasks.Hex.Policy do loaded != %{} -> {:ok, loaded} - Hex.State.fetch!(:policy) == [] -> + configured_refs() == [] -> {:ok, %{}} true -> @@ -103,6 +105,13 @@ defmodule Mix.Tasks.Hex.Policy do end end + defp configured_refs() do + case Sources.load_all() do + {:ok, refs} -> refs + :error -> [] + end + end + defp render_show(policies_map) do policies = Map.values(policies_map) Hex.Shell.info("Active policies (#{length(policies)}):\n") diff --git a/test/hex/policy/sources_test.exs b/test/hex/policy/sources_test.exs index 79b60255..1615ba91 100644 --- a/test/hex/policy/sources_test.exs +++ b/test/hex/policy/sources_test.exs @@ -1,5 +1,5 @@ defmodule Hex.Policy.SourcesTest do - use HexTest.Case + use HexTest.Case, async: false alias Hex.Policy.Sources describe "parse_config/1" do @@ -52,4 +52,53 @@ defmodule Hex.Policy.SourcesTest do assert [{"acme", "a"}, {"acme", "b"}] == Sources.dedup([{"acme", "a"}, {"acme", "b"}]) end end + + describe "load_all/0" do + setup do + original = System.get_env("HEX_POLICY") + System.delete_env("HEX_POLICY") + + on_exit(fn -> + case original do + nil -> System.delete_env("HEX_POLICY") + value -> System.put_env("HEX_POLICY", value) + end + end) + + :ok + end + + test "returns the empty set when no source contributes" do + in_tmp("policy_sources_empty", fn -> + Hex.State.put(:config_home, File.cwd!()) + assert {:ok, []} == Sources.load_all() + end) + end + + test "unions and dedups refs across env and global config" do + in_tmp("policy_sources_union", fn -> + Hex.State.put(:config_home, File.cwd!()) + Hex.Config.update(policy: "acme/baseline,myorg/strict") + + System.put_env("HEX_POLICY", "myorg/strict,acme/extra") + + assert {:ok, refs} = Sources.load_all() + + # Env entries come before global entries; dedup keeps first-seen. + assert refs == [ + {"myorg", "strict"}, + {"acme", "extra"}, + {"acme", "baseline"} + ] + end) + end + + test "returns :error when any source is malformed" do + in_tmp("policy_sources_error", fn -> + Hex.State.put(:config_home, File.cwd!()) + System.put_env("HEX_POLICY", "garbage-without-slash") + assert :error == Sources.load_all() + end) + end + end end diff --git a/test/hex/remote_converger_policy_test.exs b/test/hex/remote_converger_policy_test.exs index 012b91cc..93b5ab63 100644 --- a/test/hex/remote_converger_policy_test.exs +++ b/test/hex/remote_converger_policy_test.exs @@ -4,8 +4,20 @@ defmodule Hex.RemoteConvergerPolicyTest do alias Hex.Policy.{Diagnostics, Filter} test "with no policies configured, Hex.Policy.load_all returns empty" do - Hex.State.put(:policy, []) - assert {:ok, %{}} = Hex.Policy.load_all() + 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 + 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 test "Hex.Policy.Cooldown.strictest folds local + policies" do From a6d6242c3be83c2ef87472085edfd6fbb1449dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 17:14:45 +0200 Subject: [PATCH 11/25] Drop dead etag code in Hex.Policy.Loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 304-conditional branch was unreachable — Hex.Repo.get_policy/2 never threaded an etag through build_hex_core_config/3 — and the .etag sidecar was write-only. Removing the etag plumbing simplifies the loader and tightens the cache state machine. --- lib/hex/policy/loader.ex | 23 ++++------------------- test/hex/policy/loader_test.exs | 2 +- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex index 8c981f88..f30a3fc6 100644 --- a/lib/hex/policy/loader.ex +++ b/lib/hex/policy/loader.ex @@ -50,12 +50,9 @@ defmodule Hex.Policy.Loader do case Hex.Repo.get_policy(repo, name) do {:ok, {200, _headers, policy_map}} when is_map(policy_map) -> - write_cache(ref, policy_map, "") + write_cache(ref, policy_map) {:ok, ref, policy_map} - {:ok, {304, _headers, _}} when cache != nil -> - {:ok, ref, cache.decoded} - {:ok, {status, _, _}} when status >= 400 -> fallback(ref, cache, {:bad_status, status}) @@ -82,8 +79,6 @@ defmodule Hex.Policy.Loader do Path.join([cache_root(), @cache_subdir, repo, "#{name}.policy.term"]) end - defp etag_path(ref), do: cache_path(ref) <> ".etag" - defp cache_root() do Hex.State.fetch!(:cache_home) end @@ -92,24 +87,14 @@ defmodule Hex.Policy.Loader do path = cache_path(ref) if File.exists?(path) do - etag = - case File.read(etag_path(ref)) do - {:ok, contents} -> contents - _ -> "" - end - - %{ - decoded: :erlang.binary_to_term(File.read!(path)), - etag: etag - } + %{decoded: :erlang.binary_to_term(File.read!(path))} end end - defp write_cache(ref, decoded_map, etag) do + defp write_cache(ref, decoded_map) do path = cache_path(ref) File.mkdir_p!(Path.dirname(path)) File.write!(path, :erlang.term_to_binary(decoded_map)) - File.write!(etag_path(ref), etag || "") :ok end @@ -128,5 +113,5 @@ defmodule Hex.Policy.Loader do end @doc false - def write_cache_for_test(ref, decoded_map, etag), do: write_cache(ref, decoded_map, etag) + def write_cache_for_test(ref, decoded_map), do: write_cache(ref, decoded_map) end diff --git a/test/hex/policy/loader_test.exs b/test/hex/policy/loader_test.exs index 911f4651..1fb34272 100644 --- a/test/hex/policy/loader_test.exs +++ b/test/hex/policy/loader_test.exs @@ -85,7 +85,7 @@ defmodule Hex.Policy.LoaderTest do published_at: stale_at } - :ok = Loader.write_cache_for_test(ref, stale_payload, "\"old\"") + :ok = Loader.write_cache_for_test(ref, stale_payload) assert {:error, {:stale_cache, ^ref, days}} = Loader.fetch_many([ref]) assert days >= 30 From d07832fd583fb5c7daaf9ae134baf3451f9f366e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 17:15:26 +0200 Subject: [PATCH 12/25] Measure cache staleness by file mtime not policy published_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30-day staleness cap is about how long a network adversary can suppress refreshes — cache file age, not policy publish time. Switch days_old/1 to read File.stat mtime. The test now touches the cached file backwards in time to exercise the stale-cap path. --- lib/hex/policy/loader.ex | 10 +++++++--- test/hex/policy/loader_test.exs | 16 +++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex index f30a3fc6..22d897d8 100644 --- a/lib/hex/policy/loader.ex +++ b/lib/hex/policy/loader.ex @@ -64,7 +64,7 @@ defmodule Hex.Policy.Loader do defp fallback(ref, nil, reason), do: {:error, ref, reason} defp fallback(ref, cache, reason) do - age = days_old(cache) + age = days_old(ref) if age > @max_age_days do {:error, ref, {:stale_cache, age}} @@ -107,8 +107,12 @@ defmodule Hex.Policy.Loader do ) end - defp days_old(%{decoded: decoded}) do - seconds_ago = System.system_time(:second) - Map.fetch!(decoded, :published_at) + # Cache file age (mtime). Bounds how long a network adversary can + # suppress refreshes; orthogonal to the policy's own published_at. + defp days_old(ref) do + path = cache_path(ref) + {:ok, %File.Stat{mtime: mtime}} = File.stat(path, time: :posix) + seconds_ago = System.system_time(:second) - mtime max(0, div(seconds_ago, 86_400)) end diff --git a/test/hex/policy/loader_test.exs b/test/hex/policy/loader_test.exs index 1fb34272..31975a45 100644 --- a/test/hex/policy/loader_test.exs +++ b/test/hex/policy/loader_test.exs @@ -70,22 +70,28 @@ defmodule Hex.Policy.LoaderTest do end) end - test "hard-fails when cache exceeds 30-day staleness cap", %{bypass: bypass} do + test "hard-fails when cache file mtime exceeds 30-day staleness cap", %{bypass: bypass} do Bypass.down(bypass) in_tmp("policy_loader_stale", fn -> Hex.State.put(:cache_home, File.cwd!()) ref = {"hexpm:myorg", "strict-prod"} - stale_at = System.system_time(:second) - 31 * 86_400 - stale_payload = %{ + payload = %{ repository: "myorg", name: "strict-prod", visibility: :VISIBILITY_PUBLIC, - published_at: stale_at + published_at: System.system_time(:second) } - :ok = Loader.write_cache_for_test(ref, stale_payload) + :ok = Loader.write_cache_for_test(ref, payload) + + # Backdate the cache file's mtime past the 30-day cap. + cache_path = + Path.join([File.cwd!(), "policies", "hexpm:myorg", "strict-prod.policy.term"]) + + backdated = System.system_time(:second) - 31 * 86_400 + File.touch!(cache_path, backdated) assert {:error, {:stale_cache, ^ref, days}} = Loader.fetch_many([ref]) assert days >= 30 From 1189ed25a0fcbb3f3dbdcbd8c2678b12854f7229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 17:15:59 +0200 Subject: [PATCH 13/25] Validate package arg in mix hex.policy why Reject inputs like "myorg/" and "/foo" that produce empty repo or package halves; surface a Mix.raise instead of carrying empty strings into the registry. --- lib/mix/tasks/hex.policy.ex | 10 ++++++++-- test/mix/tasks/hex.policy_test.exs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index 9cc9ba4a..5927e7bf 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -174,8 +174,14 @@ defmodule Mix.Tasks.Hex.Policy do defp parse_package_arg(arg) do case String.split(arg, "/", parts: 2) do - [package] -> {"hexpm", package} - [repo, package] -> {repo, package} + [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 diff --git a/test/mix/tasks/hex.policy_test.exs b/test/mix/tasks/hex.policy_test.exs index 27c0c446..54451ff8 100644 --- a/test/mix/tasks/hex.policy_test.exs +++ b/test/mix/tasks/hex.policy_test.exs @@ -49,6 +49,25 @@ defmodule Mix.Tasks.Hex.PolicyTest do 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, + published_at: 0 + } + }) + + 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. From 0d00f63fc83b98e09e7daf780ff8d43fd95d0e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 17:17:02 +0200 Subject: [PATCH 14/25] Reject the global hexpm repo as a policy source The global hexpm repo carries no organization-scoped policies and Hex.Repo.get_policy/2 would have nothing useful to do with a bare "hexpm" ref. Reject "hexpm/" in both string and keyword configurations so the failure surfaces at config-parse time. --- lib/hex/policy/sources.ex | 11 +++++++++-- test/hex/policy/sources_test.exs | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/hex/policy/sources.ex b/lib/hex/policy/sources.ex index 5d6a34cc..6905324b 100644 --- a/lib/hex/policy/sources.ex +++ b/lib/hex/policy/sources.ex @@ -49,7 +49,10 @@ defmodule Hex.Policy.Sources do * a comma-separated string `"myorg/p,acme/b"` (env-var form) * `nil` or `""` (no policies) - Returns `{:ok, refs}` or `:error`. + 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, []} @@ -63,7 +66,8 @@ defmodule Hex.Policy.Sources do |> 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 -> + [repo, name] + when byte_size(repo) > 0 and byte_size(name) > 0 and repo != "hexpm" -> {:cont, {:ok, [{repo, name} | acc]}} _ -> @@ -102,6 +106,9 @@ defmodule Hex.Policy.Sources do 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}} diff --git a/test/hex/policy/sources_test.exs b/test/hex/policy/sources_test.exs index 1615ba91..990bcf8c 100644 --- a/test/hex/policy/sources_test.exs +++ b/test/hex/policy/sources_test.exs @@ -36,6 +36,12 @@ defmodule Hex.Policy.SourcesTest do 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 ") From 940221835ac8e5476693ef75464f81bd46bb2f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 18:09:33 +0200 Subject: [PATCH 15/25] Re-vendor hex_core with Policy.published_at removed Picks up the upstream removal of Policy.published_at and the contiguous renumbering (visibility=4, advisory_min_severity=5, retirement_reasons=6, cooldown=7). Drops a now-meaningless comment in the loader. --- lib/hex/policy/loader.ex | 2 +- src/mix_hex_advisory.erl | 2 +- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 2 +- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_policy.erl | 261 ++++++++++-------------- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 4 +- src/mix_hex_repo.erl | 7 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- 30 files changed, 143 insertions(+), 183 deletions(-) diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex index 22d897d8..c56ef931 100644 --- a/lib/hex/policy/loader.ex +++ b/lib/hex/policy/loader.ex @@ -108,7 +108,7 @@ defmodule Hex.Policy.Loader do end # Cache file age (mtime). Bounds how long a network adversary can - # suppress refreshes; orthogonal to the policy's own published_at. + # suppress refreshes. defp days_old(ref) do path = cache_path(ref) {:ok, %File.Stat{mtime: mtime}} = File.stat(path, time: :posix) diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 116e29f4..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 (69af1f5), 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 f28abe94..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 (69af1f5), 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 95717f95..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 (69af1f5), 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 c577ac36..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 (69af1f5), 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 92a42e81..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 (69af1f5), 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 f6c6cc03..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 (69af1f5), 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 bee811cd..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 (69af1f5), 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 daf6dd36..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 (69af1f5), 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 d898e1b4..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 (69af1f5), 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 8e70c8df..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 (69af1f5), 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 7f75b6a1..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 (69af1f5), 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 fa3936e3..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 (69af1f5), 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 e25f1d85..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 (69af1f5), 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 86a52b9e..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 (69af1f5), 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 5c5364ee..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 (69af1f5), 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 7286df42..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 (69af1f5), 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 70513b6f..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 (69af1f5), 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 6f22d848..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 (69af1f5), 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 a2541bb8..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 (69af1f5), 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 37fd889a..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 (69af1f5), 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 b4cd4413..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 (69af1f5), 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 index e0351996..d580821d 100644 --- a/src/mix_hex_pb_policy.erl +++ b/src/mix_hex_pb_policy.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (69af1f5), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated @@ -60,11 +60,10 @@ #{repository => unicode:chardata(), % = 1, required name => unicode:chardata(), % = 2, required description => unicode:chardata(), % = 3, optional - published_at => integer(), % = 4, required, 64 bits - visibility => 'VISIBILITY_PRIVATE' | 'VISIBILITY_PUBLIC' | integer(), % = 5, required, enum Visibility - advisory_min_severity => non_neg_integer(), % = 6, optional, 32 bits - retirement_reasons => [non_neg_integer()], % = 7, repeated, 32 bits - cooldown => unicode:chardata() % = 8, 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]). @@ -85,35 +84,34 @@ encode_msg(Msg, MsgName, Opts) -> encode_msg_Policy(Msg, TrUserData) -> encode_msg_Policy(Msg, <<>>, TrUserData). -encode_msg_Policy(#{repository := F1, name := F2, published_at := F4, visibility := F5} = M, Bin, 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_type_int64(TrF4, <>, TrUserData) end, - B5 = begin TrF5 = id(F5, TrUserData), e_enum_Visibility(TrF5, <>, TrUserData) end, - B6 = case M of - #{advisory_min_severity := F6} -> begin TrF6 = id(F6, TrUserData), e_varint(TrF6, <>, TrUserData) end; - _ -> B5 + 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, - B7 = case M of - #{retirement_reasons := F7} -> - TrF7 = id(F7, TrUserData), - if TrF7 == [] -> B6; - true -> e_field_Policy_retirement_reasons(TrF7, B6, TrUserData) + B6 = case M of + #{retirement_reasons := F6} -> + TrF6 = id(F6, TrUserData), + if TrF6 == [] -> B5; + true -> e_field_Policy_retirement_reasons(TrF6, B5, TrUserData) end; - _ -> B6 + _ -> B5 end, case M of - #{cooldown := F8} -> begin TrF8 = id(F8, TrUserData), e_type_string(TrF8, <>, TrUserData) end; - _ -> B7 + #{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 = <>, + Bin2 = <>, Bin3 = e_varint(byte_size(SubBin), Bin2), <>; e_field_Policy_retirement_reasons([], Bin, _TrUserData) -> Bin. @@ -254,125 +252,101 @@ decode_msg_2_doit('Policy', Bin, TrUserData) -> id(decode_msg_Policy(Bin, TrUser 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('$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, F@_8, TrUserData) -> d_field_Policy_repository(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_name(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_description(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_published_at(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> d_field_Policy_visibility(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData) -> d_pfield_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); -dfp_read_field_def_Policy(<<56, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> d_field_Policy_retirement_reasons(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); -dfp_read_field_def_Policy(<<66, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) -> d_field_Policy_cooldown(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); -dfp_read_field_def_Policy(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, R1, F@_8, TrUserData) -> - S1 = #{repository => F@_1, name => F@_2, published_at => F@_4, visibility => F@_5, retirement_reasons => lists_reverse(R1, 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@_6 == '$undef' -> S2; - true -> S2#{advisory_min_severity => F@_6} + S3 = if F@_5 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_5} end, - if F@_8 == '$undef' -> S3; - true -> S3#{cooldown => F@_8} + 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, F@_8, TrUserData) -> dg_read_field_def_Policy(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData). +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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData); - 18 -> d_field_Policy_name(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 26 -> d_field_Policy_description(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 32 -> d_field_Policy_published_at(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 40 -> d_field_Policy_visibility(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 48 -> d_field_Policy_advisory_min_severity(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 58 -> d_pfield_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 56 -> d_field_Policy_retirement_reasons(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); - 66 -> d_field_Policy_cooldown(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); + 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData) + 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, F@_6, R1, F@_8, TrUserData) -> - S1 = #{repository => F@_1, name => F@_2, published_at => F@_4, visibility => F@_5, retirement_reasons => lists_reverse(R1, TrUserData)}, +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@_6 == '$undef' -> S2; - true -> S2#{advisory_min_severity => F@_6} + S3 = if F@_5 == '$undef' -> S2; + true -> S2#{advisory_min_severity => F@_5} end, - if F@_8 == '$undef' -> S3; - true -> S3#{cooldown => F@_8} + 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, F@_8, 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, F@_8, 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, F@_8, TrUserData) -> +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, F@_8, TrUserData). + 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData). + 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData). - -d_field_Policy_published_at(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData) when N < 57 -> - d_field_Policy_published_at(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, TrUserData); -d_field_Policy_published_at(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, _, F@_5, F@_6, F@_7, F@_8, TrUserData) -> - {NewFValue, RestF} = {begin <> = <<(X bsl N + Acc):64/unsigned-native>>, id(Res, TrUserData) end, Rest}, - dfp_read_field_def_Policy(RestF, 0, 0, F, F@_1, F@_2, F@_3, NewFValue, F@_5, F@_6, F@_7, F@_8, TrUserData). + 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, F@_8, 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, F@_8, TrUserData); -d_field_Policy_visibility(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, F@_8, 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, F@_4, NewFValue, F@_6, F@_7, F@_8, TrUserData). + 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, F@_8, 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, F@_8, 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@_5, _, F@_7, F@_8, 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, F@_5, NewFValue, F@_7, F@_8, TrUserData). + 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, F@_8, 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, F@_8, 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, F@_6, Prev, F@_8, 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, F@_6, cons(NewFValue, Prev, TrUserData), F@_8, TrUserData). + 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, F@_8, 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, F@_8, 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, F@_6, E, F@_8, 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, F@_6, NewSeq, F@_8, 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) -> @@ -380,29 +354,27 @@ d_packed_field_Policy_retirement_reasons(<<0:1, X:7, Rest/binary>>, N, Acc, F, A 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, F@_8, 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, F@_8, 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, F@_7, _, TrUserData) -> +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, F@_7, NewFValue, TrUserData). + 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, F@_8, TrUserData) -> skip_varint_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, 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, F@_8, TrUserData). + 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, F@_8, 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, F@_8, TrUserData). + 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, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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, F@_8, TrUserData) -> dfp_read_field_def_Policy(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, F@_8, 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'; @@ -473,8 +445,8 @@ merge_msgs(Prev, New, MsgName, 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, published_at := NFpublished_at, visibility := NFvisibility} = NMsg, TrUserData) -> - S1 = #{repository => NFrepository, name => NFname, published_at => NFpublished_at, visibility => NFvisibility}, +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}; @@ -509,36 +481,34 @@ verify_msg(Msg, MsgName, Opts) -> -compile({nowarn_unused_function,v_msg_Policy/3}). -v_msg_Policy(#{repository := F1, name := F2, published_at := F4, visibility := F5} = M, Path, TrUserData) -> +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_type_int64(F4, [published_at | Path], TrUserData), - v_enum_Visibility(F5, [visibility | Path], TrUserData), + v_enum_Visibility(F4, [visibility | Path], TrUserData), case M of - #{advisory_min_severity := F6} -> v_type_uint32(F6, [advisory_min_severity | Path], TrUserData); + #{advisory_min_severity := F5} -> v_type_uint32(F5, [advisory_min_severity | Path], TrUserData); _ -> ok end, case M of - #{retirement_reasons := F7} -> - if is_list(F7) -> - _ = [v_type_uint32(Elem, [retirement_reasons | Path], TrUserData) || Elem <- F7], + #{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}, F7, [retirement_reasons | Path]) + true -> mk_type_error({invalid_list_of, uint32}, F6, [retirement_reasons | Path]) end; _ -> ok end, case M of - #{cooldown := F8} -> v_type_string(F8, [cooldown | Path], TrUserData); + #{cooldown := F7} -> v_type_string(F7, [cooldown | Path], TrUserData); _ -> ok end, lists:foreach(fun (repository) -> ok; (name) -> ok; (description) -> ok; - (published_at) -> ok; (visibility) -> ok; (advisory_min_severity) -> ok; (retirement_reasons) -> ok; @@ -547,7 +517,7 @@ v_msg_Policy(#{repository := F1, name := F2, published_at := F4, visibility := F end, maps:keys(M)), ok; -v_msg_Policy(M, Path, _TrUserData) when is_map(M) -> mk_type_error({missing_fields, [repository, name, published_at, visibility] -- maps:keys(M), 'Policy'}, M, Path); +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}). @@ -556,11 +526,6 @@ 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_int64/3}). -v_type_int64(N, _Path, _TrUserData) when is_integer(N), -9223372036854775808 =< N, N =< 9223372036854775807 -> ok; -v_type_int64(N, Path, _TrUserData) when is_integer(N) -> mk_type_error({value_out_of_range, int64, signed, 64}, N, Path); -v_type_int64(X, Path, _TrUserData) -> mk_type_error({bad_integer, int64, signed, 64}, 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); @@ -618,11 +583,10 @@ get_msg_defs() -> [#{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 => published_at, fnum => 4, rnum => 5, type => int64, occurrence => required, opts => []}, - #{name => visibility, fnum => 5, rnum => 6, type => {enum, 'Visibility'}, occurrence => required, opts => []}, - #{name => advisory_min_severity, fnum => 6, rnum => 7, type => uint32, occurrence => optional, opts => []}, - #{name => retirement_reasons, fnum => 7, rnum => 8, type => uint32, occurrence => repeated, opts => [packed]}, - #{name => cooldown, fnum => 8, rnum => 9, 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']. @@ -655,11 +619,10 @@ 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 => published_at, fnum => 4, rnum => 5, type => int64, occurrence => required, opts => []}, - #{name => visibility, fnum => 5, rnum => 6, type => {enum, 'Visibility'}, occurrence => required, opts => []}, - #{name => advisory_min_severity, fnum => 6, rnum => 7, type => uint32, occurrence => optional, opts => []}, - #{name => retirement_reasons, fnum => 7, rnum => 8, type => uint32, occurrence => repeated, opts => [packed]}, - #{name => cooldown, fnum => 8, rnum => 9, 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. diff --git a/src/mix_hex_pb_signed.erl b/src/mix_hex_pb_signed.erl index a4c37312..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 (69af1f5), 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 c23ce3be..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 (69af1f5), 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 865c9bbf..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 (69af1f5), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. @@ -145,7 +145,7 @@ 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} = Result -> + #{repository := Repository, name := Name, visibility := _} = Result -> {ok, Result}; _ -> {error, bad_repo_name} diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index a5e631a7..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 (69af1f5), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %% @doc %% Repo API. @@ -112,10 +112,7 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) -> 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, - "mix_hex_repo:get_policy/2 requires repo_organization to be set"} - ); + {error, missing_repo_organization}; Org when is_binary(Org) -> Verify = maps:get(repo_verify_origin, Config, true), Decoder = fun(Data) -> diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index 53cba9a8..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 (69af1f5), 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 c5f94770..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 (69af1f5), 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 085452e6..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 (69af1f5), do not edit manually +%% Vendored from hex_core v0.17.0 (364a64d), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. From 2a471634ff313e427f7e4da1c4add2da46383854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 20:35:26 +0200 Subject: [PATCH 16/25] Drop published_at from policy test fixtures --- test/hex/policy/filter_test.exs | 3 +-- test/hex/policy/loader_test.exs | 6 ++---- test/hex/registry/policy_test.exs | 3 --- test/mix/tasks/hex.policy_test.exs | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/test/hex/policy/filter_test.exs b/test/hex/policy/filter_test.exs index 7ba3ee15..0653e32a 100644 --- a/test/hex/policy/filter_test.exs +++ b/test/hex/policy/filter_test.exs @@ -7,8 +7,7 @@ defmodule Hex.Policy.FilterTest do %{ repository: "myorg", name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: 1_700_000_000 + visibility: :VISIBILITY_PUBLIC }, overrides ) diff --git a/test/hex/policy/loader_test.exs b/test/hex/policy/loader_test.exs index 31975a45..685b71d5 100644 --- a/test/hex/policy/loader_test.exs +++ b/test/hex/policy/loader_test.exs @@ -31,8 +31,7 @@ defmodule Hex.Policy.LoaderTest do base = %{ repository: "myorg", name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second) + visibility: :VISIBILITY_PUBLIC } signed_policy(Map.merge(base, Map.new(extra))) @@ -80,8 +79,7 @@ defmodule Hex.Policy.LoaderTest do payload = %{ repository: "myorg", name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second) + visibility: :VISIBILITY_PUBLIC } :ok = Loader.write_cache_for_test(ref, payload) diff --git a/test/hex/registry/policy_test.exs b/test/hex/registry/policy_test.exs index beef9fc3..3bea85ec 100644 --- a/test/hex/registry/policy_test.exs +++ b/test/hex/registry/policy_test.exs @@ -62,7 +62,6 @@ defmodule Hex.Registry.PolicyTest do repository: "myorg", name: "strict", visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second), advisory_min_severity: 3 } }) @@ -83,7 +82,6 @@ defmodule Hex.Registry.PolicyTest do repository: "myorg", name: "no-security-retired", visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second), retirement_reasons: [2] } }) @@ -103,7 +101,6 @@ defmodule Hex.Registry.PolicyTest do repository: "myorg", name: "strict", visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second), advisory_min_severity: 3 } }) diff --git a/test/mix/tasks/hex.policy_test.exs b/test/mix/tasks/hex.policy_test.exs index 54451ff8..44380810 100644 --- a/test/mix/tasks/hex.policy_test.exs +++ b/test/mix/tasks/hex.policy_test.exs @@ -21,7 +21,6 @@ defmodule Mix.Tasks.Hex.PolicyTest do repository: "myorg", name: "strict-prod", visibility: :VISIBILITY_PUBLIC, - published_at: System.system_time(:second), cooldown: "14d", advisory_min_severity: 3, retirement_reasons: [1, 2] @@ -54,8 +53,7 @@ defmodule Mix.Tasks.Hex.PolicyTest do {"hexpm:myorg", "strict-prod"} => %{ repository: "myorg", name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: 0 + visibility: :VISIBILITY_PUBLIC } }) From d9242eb186770eea4e009bce33452d9616d61234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 22 May 2026 20:35:33 +0200 Subject: [PATCH 17/25] Remove policy pre-flight check Resolution can succeed even when individual versions are blocked by an active policy, so failing before the solver runs is wrong. Let the solver explore the candidate set; Hex.Registry.Policy filters at solve time and Diagnostics.failure_note attributes blocks on failure. --- lib/hex/policy/diagnostics.ex | 41 +------------ lib/hex/remote_converger.ex | 72 ----------------------- test/hex/mix_task_test.exs | 8 +-- test/hex/policy/diagnostics_test.exs | 25 +------- test/hex/remote_converger_policy_test.exs | 69 ---------------------- 5 files changed, 6 insertions(+), 209 deletions(-) diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex index 90b4293d..881b5511 100644 --- a/lib/hex/policy/diagnostics.ex +++ b/lib/hex/policy/diagnostics.ex @@ -73,48 +73,9 @@ defmodule Hex.Policy.Diagnostics do |> Enum.reject(&is_nil/1) end - @doc """ - Renders a focused pre-flight error for a direct dep whose constraint - has no eligible version under the active policies. - - `package` is the package name, `requirement` the original Hex - requirement string, `version_blockers` a list of - `{version_string, [blocker]}` tuples. - """ - @spec preflight_error(String.t(), String.t(), [{String.t(), [map()]}], [map()]) :: - String.t() - def preflight_error(package, requirement, version_blockers, active_policies) do - blocked_lines = - Enum.map(version_blockers, fn {version, blockers} -> - attribution = blockers |> Enum.map(&format_blocker/1) |> Enum.join(", ") - " #{package} #{version} — #{attribution}" - end) - - active_lines = - Enum.map(active_policies, fn p -> - " * #{p.repository}/#{p.name}" - end) - - """ - Hex dependency resolution failed - - All versions of "#{package}" matching "#{requirement}" are blocked by active policies: - - #{Enum.join(blocked_lines, "\n")} - - Active policies: - #{Enum.join(active_lines, "\n")} - - To proceed: - * Update one or more policies to allow the version you need - * Adjust your version requirement to a permitted range - * Remove the offending source (e.g. `policy:` from mix.exs or `HEX_POLICY` env var) - """ - end - @doc """ Renders a Note: block to append to a solver failure when active - policies emptied a transitive dep's candidate set. + policies hid candidate versions. Returns `nil` if there's nothing relevant to say. """ diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index f5032506..796527fb 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -78,8 +78,6 @@ defmodule Hex.RemoteConverger do verify_deps(deps, Hex.Mix.top_level(deps)) verify_input(requests, locked) - policy_preflight!(requests) - Hex.Shell.info("Resolving Hex dependencies...") run_solver(lock, old_lock, requests, locked, overridden) end @@ -924,74 +922,4 @@ defmodule Hex.RemoteConverger do end end end - - defp policy_preflight!(requests) do - policies = Map.values(Hex.State.fetch!(:policies)) - - if policies != [] do - Enum.each(requests, fn request -> - check_request_against_policies(request, policies) - end) - end - end - - defp check_request_against_policies(%{repo: repo, name: name, requirement: req}, policies) - when is_binary(name) do - case Registry.versions(repo, name) do - {:ok, all_versions} -> - matching = - if req do - {:ok, parsed_req} = Version.parse_requirement(req) - Enum.filter(all_versions, fn v -> Version.match?(v, parsed_req) end) - else - all_versions - end - - if matching != [] do - per_version_blockers = - for v <- matching do - release = build_release_for_preflight(repo, name, v) - - case Hex.Policy.Filter.classify_set(policies, release) do - :allowed -> {v, []} - {:blocked, blockers} -> {v, blockers} - end - end - - any_allowed? = Enum.any?(per_version_blockers, fn {_, b} -> b == [] end) - - if not any_allowed? do - version_blockers = - Enum.map(per_version_blockers, fn {v, blockers} -> - {to_string(v), blockers} - end) - - Mix.raise( - Hex.Policy.Diagnostics.preflight_error( - name, - req || "*", - version_blockers, - policies - ) - ) - end - end - - :error -> - :ok - end - end - - defp check_request_against_policies(_, _), do: :ok - - defp build_release_for_preflight(repo, package, version) do - version_str = to_string(version) - - %{ - version: version_str, - advisories: - Hex.Policy.Filter.normalize_advisories(Registry.advisories(repo, package, version_str)), - retired: Registry.retired(repo, package, version_str) - } - end end 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 index e34c280f..f41ba276 100644 --- a/test/hex/policy/diagnostics_test.exs +++ b/test/hex/policy/diagnostics_test.exs @@ -4,7 +4,7 @@ defmodule Hex.Policy.DiagnosticsTest do defp policy(opts) do Map.merge( - %{repository: "myorg", visibility: :VISIBILITY_PUBLIC, published_at: 0}, + %{repository: "myorg", visibility: :VISIBILITY_PUBLIC}, Map.new(opts) ) end @@ -37,29 +37,6 @@ defmodule Hex.Policy.DiagnosticsTest do end end - describe "preflight_error/4" do - test "renders the expected block" do - pol = policy(name: "strict-prod") - - out = - Diagnostics.preflight_error( - "phoenix", - "~> 1.7", - [ - {"1.7.18", [%{policy: pol, reason: {:advisory, 3}}]}, - {"1.7.19", [%{policy: pol, reason: {:retirement, 2}}]} - ], - [pol] - ) - - assert out =~ "Hex dependency resolution failed" - assert out =~ ~s|All versions of "phoenix" matching "~> 1.7"| - assert out =~ "myorg/strict-prod (advisory ≥ high)" - assert out =~ "myorg/strict-prod (retirement: security)" - assert out =~ "Active policies:\n * myorg/strict-prod" - end - end - describe "failure_note/1" do test "returns nil when nothing filtered" do assert Diagnostics.failure_note([]) == nil diff --git a/test/hex/remote_converger_policy_test.exs b/test/hex/remote_converger_policy_test.exs index 93b5ab63..fa7fd8bc 100644 --- a/test/hex/remote_converger_policy_test.exs +++ b/test/hex/remote_converger_policy_test.exs @@ -1,8 +1,6 @@ defmodule Hex.RemoteConvergerPolicyTest do use HexTest.Case - alias Hex.Policy.{Diagnostics, Filter} - 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!()) @@ -26,71 +24,4 @@ defmodule Hex.RemoteConvergerPolicyTest do %{repository: "myorg", name: "p", cooldown: "14d"} ]) end - - describe "pre-flight diagnostic shape" do - # Focused diagnostic test (not a full converge integration). The full - # converge run requires substantial registry-fixture wiring; the - # pre-flight scan's two moving parts — `Hex.Policy.Filter.classify_set` - # producing blockers and `Hex.Policy.Diagnostics.preflight_error` - # rendering them — are exercised here in isolation. Rendering details - # already covered by `Hex.Policy.DiagnosticsTest`. - - test "Filter.classify_set returns blocker shape that Diagnostics.preflight_error renders" do - policies = [ - %{ - repository: "myorg", - name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: 0, - advisory_min_severity: 3 - } - ] - - # Two unsafe versions of phoenix: both flagged by the policy. - releases = [ - {"1.7.18", - %{ - version: "1.7.18", - advisories: Filter.normalize_advisories([%{severity: :SEVERITY_HIGH}]), - retired: nil - }}, - {"1.7.19", - %{ - version: "1.7.19", - advisories: Filter.normalize_advisories([%{severity: :SEVERITY_CRITICAL}]), - retired: nil - }} - ] - - version_blockers = - Enum.map(releases, fn {v, release} -> - {:blocked, blockers} = Filter.classify_set(policies, release) - {v, blockers} - end) - - # The result is what `policy_preflight!` hands to Diagnostics. - out = Diagnostics.preflight_error("phoenix", "~> 1.7", version_blockers, policies) - - assert out =~ "Hex dependency resolution failed" - assert out =~ ~s|All versions of "phoenix" matching "~> 1.7"| - assert out =~ "phoenix 1.7.18" - assert out =~ "phoenix 1.7.19" - assert out =~ "myorg/strict-prod (advisory ≥ high)" - end - - test "Filter.classify_set returns :allowed when any version slips through" do - policies = [ - %{ - repository: "myorg", - name: "strict-prod", - visibility: :VISIBILITY_PUBLIC, - published_at: 0, - advisory_min_severity: 3 - } - ] - - clean_release = %{version: "1.7.20", advisories: [], retired: nil} - assert :allowed = Filter.classify_set(policies, clean_release) - end - end end From c33401068ec0c8239677fb222db23288443cae57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:00:43 +0200 Subject: [PATCH 18/25] Fetch policies through Hex.Registry.Server Drop the bespoke Hex.Policy.Loader and route policy fetches through the same ETS-backed cache, Hex.Parallel runner, and etag/304 path that handles /packages/. Adds prefetch_policies/1 + policy/2 to Hex.Registry.Server, get_policy/3 etag arg to Hex.Repo, and honors HEX_OFFLINE. --- lib/hex/policy.ex | 34 +++-- lib/hex/policy/diagnostics.ex | 12 +- lib/hex/policy/loader.ex | 121 ---------------- lib/hex/registry/server.ex | 174 ++++++++++++++++++++++- lib/hex/repo.ex | 8 +- test/hex/policy/diagnostics_test.exs | 11 +- test/hex/policy/loader_test.exs | 98 ------------- test/hex/registry/server_policy_test.exs | 96 +++++++++++++ 8 files changed, 308 insertions(+), 246 deletions(-) delete mode 100644 lib/hex/policy/loader.ex delete mode 100644 test/hex/policy/loader_test.exs create mode 100644 test/hex/registry/server_policy_test.exs diff --git a/lib/hex/policy.ex b/lib/hex/policy.ex index b6d253a1..66ab982b 100644 --- a/lib/hex/policy.ex +++ b/lib/hex/policy.ex @@ -1,22 +1,40 @@ defmodule Hex.Policy do @moduledoc false - alias Hex.Policy.{Sources, Loader} + 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, and returns the active - set as a `%{ref => policy}` map. + global), unions them, fetches each policy through the registry, + and returns the active set as a `%{ref => policy}` map. - Returns `{:error, reason}` if any policy fails to load AND has no - usable cache within the staleness cap, or `{:error, - :invalid_policy_config}` if any source has a malformed value. + 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, refs} -> Loader.fetch_many(refs) - :error -> {:error, :invalid_policy_config} + {: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 end diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex index 881b5511..dbe07158 100644 --- a/lib/hex/policy/diagnostics.ex +++ b/lib/hex/policy/diagnostics.ex @@ -101,16 +101,12 @@ defmodule Hex.Policy.Diagnostics do end @doc """ - Formats a `Hex.Policy.Loader` load error for `Mix.raise/1`. + Formats a `Hex.Policy.load_all/0` error for `Mix.raise/1`. """ @spec format_load_error(term()) :: String.t() - def format_load_error({:stale_cache, {repo, name}, days}) do - "Policy #{inspect(name)} of #{inspect(repo)} cache is #{days} days old " <> - "(max 30) and the registry is unreachable." - end - - def format_load_error({:fetch, {repo, name}, reason}) do - "Failed to fetch policy #{inspect(name)} of #{inspect(repo)}: #{inspect(reason)}" + 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)}" diff --git a/lib/hex/policy/loader.ex b/lib/hex/policy/loader.ex deleted file mode 100644 index c56ef931..00000000 --- a/lib/hex/policy/loader.ex +++ /dev/null @@ -1,121 +0,0 @@ -defmodule Hex.Policy.Loader do - @moduledoc false - - alias Hex.Policy.Sources - - @max_age_days 30 - @cache_subdir "policies" - - @type ref :: Sources.ref() - @type policy :: map() - @type error_reason :: - {:stale_cache, ref(), pos_integer()} - | {:fetch, ref(), term()} - - @doc """ - Fetches every policy in `refs` in parallel. - - Returns `{:ok, %{ref => policy}}` on success or `{:error, reason}` - if any policy fails to load AND has no usable cache within the - staleness cap. - """ - @spec fetch_many([ref()]) :: {:ok, %{ref() => policy()}} | {:error, error_reason()} - def fetch_many([]), do: {:ok, %{}} - - def fetch_many(refs) do - refs - |> Enum.uniq() - |> Task.async_stream(&fetch_one/1, max_concurrency: 5, timeout: 30_000) - |> Enum.reduce_while({:ok, %{}}, fn - {:ok, {:ok, ref, policy}}, {:ok, acc} -> - {:cont, {:ok, Map.put(acc, ref, policy)}} - - {:ok, {:error, _ref, _reason} = error}, _acc -> - {:halt, {:error, normalize_error(error)}} - - {:exit, reason}, _acc -> - {:halt, {:error, {:fetch, :unknown, reason}}} - end) - end - - defp normalize_error({:error, ref, {:stale_cache, days}}), - do: {:stale_cache, ref, days} - - defp normalize_error({:error, ref, reason}), - do: {:fetch, ref, reason} - - defp fetch_one(ref) do - {repo, name} = ref - cache = read_cache(ref) - - case Hex.Repo.get_policy(repo, name) do - {:ok, {200, _headers, policy_map}} when is_map(policy_map) -> - write_cache(ref, policy_map) - {:ok, ref, policy_map} - - {:ok, {status, _, _}} when status >= 400 -> - fallback(ref, cache, {:bad_status, status}) - - {:error, reason} -> - fallback(ref, cache, reason) - end - end - - defp fallback(ref, nil, reason), do: {:error, ref, reason} - - defp fallback(ref, cache, reason) do - age = days_old(ref) - - if age > @max_age_days do - {:error, ref, {:stale_cache, age}} - else - warn_stale(ref, age, reason) - {:ok, ref, cache.decoded} - end - end - - defp cache_path(ref) do - {repo, name} = ref - Path.join([cache_root(), @cache_subdir, repo, "#{name}.policy.term"]) - end - - defp cache_root() do - Hex.State.fetch!(:cache_home) - end - - defp read_cache(ref) do - path = cache_path(ref) - - if File.exists?(path) do - %{decoded: :erlang.binary_to_term(File.read!(path))} - end - end - - defp write_cache(ref, decoded_map) do - path = cache_path(ref) - File.mkdir_p!(Path.dirname(path)) - File.write!(path, :erlang.term_to_binary(decoded_map)) - :ok - end - - defp warn_stale(ref, age, _reason) do - {repo, name} = ref - - Hex.Shell.warn( - "Policy #{inspect(name)} of #{inspect(repo)} is from cache " <> - "(#{age} days old) — could not refresh" - ) - end - - # Cache file age (mtime). Bounds how long a network adversary can - # suppress refreshes. - defp days_old(ref) do - path = cache_path(ref) - {:ok, %File.Stat{mtime: mtime}} = File.stat(path, time: :posix) - seconds_ago = System.system_time(:second) - mtime - max(0, div(seconds_ago, 86_400)) - end - - @doc false - def write_cache_for_test(ref, decoded_map), do: write_cache(ref, decoded_map) -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/repo.ex b/lib/hex/repo.ex index c2a93972..6fa3cc39 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -232,13 +232,13 @@ defmodule Hex.Repo do 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` raises if - `repo_organization` is unset. + 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) do + def get_policy(repo, name, etag \\ nil) do repo_config = get_repo(repo) organization = repo_organization(repo) - config = build_hex_core_config(repo_config, repo) + config = build_hex_core_config(repo_config, repo, etag) config = put_repo_organization(config, repo_config, organization) :mix_hex_repo.get_policy(config, name) end diff --git a/test/hex/policy/diagnostics_test.exs b/test/hex/policy/diagnostics_test.exs index f41ba276..6537416c 100644 --- a/test/hex/policy/diagnostics_test.exs +++ b/test/hex/policy/diagnostics_test.exs @@ -68,14 +68,13 @@ defmodule Hex.Policy.DiagnosticsTest do end describe "format_load_error/1" do - test "stale_cache" do - assert Diagnostics.format_load_error({:stale_cache, {"hexpm:myorg", "strict-prod"}, 31}) =~ - "cache is 31 days old" + test "invalid_policy_config" do + assert Diagnostics.format_load_error(:invalid_policy_config) =~ + "Policy configuration is invalid" end - test "fetch error" do - assert Diagnostics.format_load_error({:fetch, {"hexpm:myorg", "strict-prod"}, :timeout}) =~ - "Failed to fetch policy" + test "fallback" do + assert Diagnostics.format_load_error(:something) =~ "Policy loading failed" end end end diff --git a/test/hex/policy/loader_test.exs b/test/hex/policy/loader_test.exs deleted file mode 100644 index 685b71d5..00000000 --- a/test/hex/policy/loader_test.exs +++ /dev/null @@ -1,98 +0,0 @@ -defmodule Hex.Policy.LoaderTest do - use HexTest.Case, async: false - - alias Hex.Policy.Loader - - 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 - }) - - 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 "fetches and decodes a policy", %{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("policy_loader_fetch", fn -> - Hex.State.put(:cache_home, File.cwd!()) - - assert {:ok, policies} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) - policy = Map.fetch!(policies, {"hexpm:myorg", "strict-prod"}) - assert policy.name == "strict-prod" - assert policy.advisory_min_severity == 3 - end) - end - - test "uses last-known-good cache on network failure", %{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("policy_loader_cache_fallback", fn -> - Hex.State.put(:cache_home, File.cwd!()) - {:ok, _} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) - - Bypass.down(bypass) - assert {:ok, policies} = Loader.fetch_many([{"hexpm:myorg", "strict-prod"}]) - assert Map.fetch!(policies, {"hexpm:myorg", "strict-prod"}).name == "strict-prod" - end) - end - - test "hard-fails when cache file mtime exceeds 30-day staleness cap", %{bypass: bypass} do - Bypass.down(bypass) - - in_tmp("policy_loader_stale", fn -> - Hex.State.put(:cache_home, File.cwd!()) - ref = {"hexpm:myorg", "strict-prod"} - - payload = %{ - repository: "myorg", - name: "strict-prod", - visibility: :VISIBILITY_PUBLIC - } - - :ok = Loader.write_cache_for_test(ref, payload) - - # Backdate the cache file's mtime past the 30-day cap. - cache_path = - Path.join([File.cwd!(), "policies", "hexpm:myorg", "strict-prod.policy.term"]) - - backdated = System.system_time(:second) - 31 * 86_400 - File.touch!(cache_path, backdated) - - assert {:error, {:stale_cache, ^ref, days}} = Loader.fetch_many([ref]) - assert days >= 30 - end) - 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..98bdd134 --- /dev/null +++ b/test/hex/registry/server_policy_test.exs @@ -0,0 +1,96 @@ +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 + }) + + 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 From 251b0f90e48b49befc451bf4ae03941ba3962fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:22:20 +0200 Subject: [PATCH 19/25] Keep advisory severity and retirement reason in atom space in Hex.Policy.Filter The filter carried two parallel representations of every enum: hex_core decoded atoms from the registry, plus an int form invented to match the threshold passed in by the policy resource. Converting the threshold to its atom symbol once and ranking with Enum.find_index removes the severity_to_int / retired_to_int / normalize_advisories helpers and lets blockers describe themselves with the symbol the rest of the codebase already speaks. --- lib/hex/policy/filter.ex | 81 ++++++++++++++++--------------- lib/hex/registry/policy.ex | 14 +----- lib/mix/tasks/hex.policy.ex | 10 +--- test/hex/policy/filter_test.exs | 29 +++++------ test/hex/registry/policy_test.exs | 9 +++- 5 files changed, 62 insertions(+), 81 deletions(-) diff --git a/lib/hex/policy/filter.ex b/lib/hex/policy/filter.ex index 3b34768f..04f1487d 100644 --- a/lib/hex/policy/filter.ex +++ b/lib/hex/policy/filter.ex @@ -1,9 +1,19 @@ 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, non_neg_integer()} | {:retirement, non_neg_integer()} + @type reason :: {:advisory, atom()} | {:retirement, atom()} @type blocker :: %{policy: policy(), reason: reason()} @doc """ @@ -39,12 +49,33 @@ defmodule Hex.Policy.Filter do 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 -> Map.get(a, :severity, 0) >= threshold end) do - [{:advisory, threshold} | reasons] + if Enum.any?(advisories, fn a -> severity_rank(Map.get(a, :severity)) >= threshold_rank end) do + [{:advisory, threshold_atom} | reasons] else reasons end @@ -55,9 +86,11 @@ defmodule Hex.Policy.Filter do 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: r} -> - r_int = retired_to_int(r) - if r_int in ret_reasons, do: [{:retirement, r_int} | reasons], else: reasons + %{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 @@ -66,39 +99,7 @@ defmodule Hex.Policy.Filter do defp add_retirement(reasons, _policy, _release), do: reasons - defp retired_to_int(:RETIRED_OTHER), do: 0 - defp retired_to_int(:RETIRED_INVALID), do: 1 - defp retired_to_int(:RETIRED_SECURITY), do: 2 - defp retired_to_int(:RETIRED_DEPRECATED), do: 3 - defp retired_to_int(:RETIRED_RENAMED), do: 4 - defp retired_to_int(int) when is_integer(int), do: int - - @doc """ - Normalizes a list of advisories to use integer severities. - - Accepts `nil` (treated as empty list). Each advisory's `:severity` is - converted from the hex_core atom representation - (`:SEVERITY_NONE`..`:SEVERITY_CRITICAL`) into an integer in `0..4`. - Integer severities are passed through; unknown values become `0`. - """ - @spec normalize_advisories(nil | [map()]) :: [map()] - def normalize_advisories(nil), do: [] - - def normalize_advisories(advisories) when is_list(advisories) do - Enum.map(advisories, fn advisory -> - Map.update(advisory, :severity, 0, &severity_to_int/1) - end) + defp severity_rank(severity) do + Enum.find_index(@severity_order, &(&1 == severity)) || 0 end - - @doc """ - Converts an advisory severity (atom or integer) to its integer form. - """ - @spec severity_to_int(atom() | integer()) :: 0..4 - def severity_to_int(:SEVERITY_NONE), do: 0 - def severity_to_int(:SEVERITY_LOW), do: 1 - def severity_to_int(:SEVERITY_MEDIUM), do: 2 - def severity_to_int(:SEVERITY_HIGH), do: 3 - def severity_to_int(:SEVERITY_CRITICAL), do: 4 - def severity_to_int(int) when is_integer(int), do: int - def severity_to_int(_), do: 0 end diff --git a/lib/hex/registry/policy.ex b/lib/hex/registry/policy.ex index 59b451a8..1440cdb1 100644 --- a/lib/hex/registry/policy.ex +++ b/lib/hex/registry/policy.ex @@ -3,7 +3,7 @@ defmodule Hex.Registry.Policy do @behaviour Hex.Solver.Registry - alias Hex.Registry.{Cooldown, Server} + alias Hex.Registry.Cooldown alias Hex.Policy.Filter @impl true @@ -31,7 +31,7 @@ defmodule Hex.Registry.Policy do defp filter(versions, repo, package, policies) do Enum.filter(versions, fn version -> - release = build_release(repo, package, version) + release = Filter.release_from_registry(repo, package, version) case Filter.classify_set(policies, release) do :allowed -> @@ -44,16 +44,6 @@ defmodule Hex.Registry.Policy do end) end - defp build_release(repo, package, version) do - version_str = to_string(version) - - %{ - version: version_str, - advisories: Filter.normalize_advisories(Server.advisories(repo, package, version_str)), - retired: Server.retired(repo, package, version_str) - } - end - defp record_block(repo, package, version, blockers) do entry = %{ repo: repo || "hexpm", diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index 5927e7bf..b1627462 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -191,15 +191,7 @@ defmodule Mix.Tasks.Hex.Policy do Enum.each(versions, fn v -> version = to_string(v) - - release = %{ - version: version, - advisories: - Filter.normalize_advisories( - Hex.Registry.Server.advisories(repo, package, version) || [] - ), - retired: Hex.Registry.Server.retired(repo, package, version) - } + release = Filter.release_from_registry(repo, package, version) case Filter.classify_set(policies, release) do :allowed -> diff --git a/test/hex/policy/filter_test.exs b/test/hex/policy/filter_test.exs index 0653e32a..cdecd5da 100644 --- a/test/hex/policy/filter_test.exs +++ b/test/hex/policy/filter_test.exs @@ -20,14 +20,14 @@ defmodule Hex.Policy.FilterTest do describe "classify/3 — advisory rule" do test "blocks when release advisory >= threshold" do p = policy(%{advisory_min_severity: 3}) - r = release(%{advisories: [%{severity: 3}]}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) assert {:blocked, reasons} = Filter.classify(p, r, []) - assert {:advisory, 3} in reasons + assert {:advisory, :SEVERITY_HIGH} in reasons end test "allows when release advisory < threshold" do p = policy(%{advisory_min_severity: 3}) - r = release(%{advisories: [%{severity: 1}]}) + r = release(%{advisories: [%{severity: :SEVERITY_LOW}]}) assert :allowed == Filter.classify(p, r, []) end @@ -39,29 +39,22 @@ defmodule Hex.Policy.FilterTest do test "allows when policy has no advisory rule" do p = policy() - r = release(%{advisories: [%{severity: 4}]}) + 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 (integer)" do - p = policy(%{retirement_reasons: [2, 3]}) - r = release(%{retired: %{reason: 2}}) - assert {:blocked, reasons} = Filter.classify(p, r, []) - assert {:retirement, 2} in reasons - end - - test "blocks when release retired with selected reason (atom)" 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, 2} in reasons + 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: 4}}) + r = release(%{retired: %{reason: :RETIRED_RENAMED}}) assert :allowed == Filter.classify(p, r, []) end @@ -73,7 +66,7 @@ defmodule Hex.Policy.FilterTest do test "allows when policy has no retirement rule" do p = policy() - r = release(%{retired: %{reason: 2}}) + r = release(%{retired: %{reason: :RETIRED_SECURITY}}) assert :allowed == Filter.classify(p, r, []) end end @@ -82,7 +75,7 @@ defmodule Hex.Policy.FilterTest 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: 3}]}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) assert {:blocked, blockers} = Filter.classify_set([p1, p2], r) assert length(blockers) == 1 @@ -92,7 +85,7 @@ defmodule Hex.Policy.FilterTest do 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: 3}]}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) assert {:blocked, blockers} = Filter.classify_set([p1, p2], r) assert length(blockers) == 2 @@ -103,7 +96,7 @@ defmodule Hex.Policy.FilterTest do 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: 3}]}) + r = release(%{advisories: [%{severity: :SEVERITY_HIGH}]}) assert :allowed == Filter.classify_set([p1, p2], r) end diff --git a/test/hex/registry/policy_test.exs b/test/hex/registry/policy_test.exs index 3bea85ec..bd5d2be1 100644 --- a/test/hex/registry/policy_test.exs +++ b/test/hex/registry/policy_test.exs @@ -73,7 +73,9 @@ defmodule Hex.Registry.PolicyTest do assert entry.repo == "hexpm" assert entry.package == "advised" assert entry.version == "1.0.0" - assert [%{policy: %{name: "strict"}, reason: {:advisory, 3}}] = entry.blockers + + assert [%{policy: %{name: "strict"}, reason: {:advisory, :SEVERITY_HIGH}}] = + entry.blockers end test "filters versions that a retirement rule blocks" do @@ -92,7 +94,10 @@ defmodule Hex.Registry.PolicyTest do [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, 2}}] = entry.blockers + + assert [ + %{policy: %{name: "no-security-retired"}, reason: {:retirement, :RETIRED_SECURITY}} + ] = entry.blockers end test "no diagnostics recorded when nothing is blocked" do From 9d6738cd7427afa2056025abf3f0f436a39e9d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:25:46 +0200 Subject: [PATCH 20/25] Fold cooldown setup into Hex.Cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hex.Policy.Cooldown duplicated duration parsing and source attribution that already lived in Hex.Cooldown, and the remote converger held a second copy of the bypass/locked-versions builders. Collapsing both into Hex.Cooldown — strictest/1 over a tagged list, setup/2 as the single entry point — drops a module and the converger's private helpers. --- lib/hex/cooldown.ex | 108 ++++++++++++++++++++ lib/hex/policy/cooldown.ex | 49 --------- lib/hex/policy/diagnostics.ex | 13 ++- lib/hex/remote_converger.ex | 65 +----------- lib/mix/tasks/hex.policy.ex | 14 ++- test/hex/cooldown_test.exs | 54 ++++++++++ test/hex/policy/cooldown_test.exs | 57 ----------- test/hex/remote_converger_cooldown_test.exs | 77 +++++++------- test/hex/remote_converger_policy_test.exs | 8 +- 9 files changed, 225 insertions(+), 220 deletions(-) delete mode 100644 lib/hex/policy/cooldown.ex delete mode 100644 test/hex/policy/cooldown_test.exs diff --git a/lib/hex/cooldown.ex b/lib/hex/cooldown.ex index d673b1aa..9c935259 100644 --- a/lib/hex/cooldown.ex +++ b/lib/hex/cooldown.ex @@ -83,6 +83,114 @@ 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. diff --git a/lib/hex/policy/cooldown.ex b/lib/hex/policy/cooldown.ex deleted file mode 100644 index 15a03d5b..00000000 --- a/lib/hex/policy/cooldown.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Hex.Policy.Cooldown do - @moduledoc false - - alias Hex.Cooldown - - @doc """ - Returns the strictest (longest) cooldown duration string across the - local config and the active policy set. - """ - @spec strictest(String.t() | nil, [map()]) :: String.t() - def strictest(local, policies) do - candidates = [{:local, normalize(local), to_seconds(local)} | policy_durations(policies)] - {_tag, duration, _seconds} = Enum.max_by(candidates, &elem(&1, 2)) - duration - end - - @doc """ - Returns the source of the strictest cooldown — `:local` or - `{repo, name}` of a policy. - """ - @spec source(String.t() | nil, [map()]) :: :local | {String.t(), String.t()} - def source(local, policies) do - candidates = [{:local, normalize(local), to_seconds(local)} | policy_durations(policies)] - {tag, _duration, _seconds} = Enum.max_by(candidates, &elem(&1, 2)) - tag - end - - defp policy_durations(policies) do - for p <- policies, - cd = Map.get(p, :cooldown), - cd not in [nil, ""] do - {{Map.fetch!(p, :repository), Map.fetch!(p, :name)}, cd, to_seconds(cd)} - end - end - - defp to_seconds(nil), do: 0 - defp to_seconds(""), do: 0 - - defp to_seconds(s) when is_binary(s) do - case Cooldown.duration_to_seconds(s) do - {:ok, n} -> n - :error -> 0 - end - end - - defp normalize(nil), do: "0d" - defp normalize(""), do: "0d" - defp normalize(s), do: s -end diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex index dbe07158..3a413313 100644 --- a/lib/hex/policy/diagnostics.ex +++ b/lib/hex/policy/diagnostics.ex @@ -1,7 +1,7 @@ defmodule Hex.Policy.Diagnostics do @moduledoc false - alias Hex.Policy.Cooldown + alias Hex.Cooldown @type filtered_entry :: %{ repo: String.t(), @@ -31,13 +31,16 @@ defmodule Hex.Policy.Diagnostics do header = "Active policies: #{Enum.join(refs, ", ")} (#{length(policies)})" cooldown_line = - case Cooldown.strictest(local_cooldown, policies) do - "0d" -> + case Cooldown.strictest([ + {:local, local_cooldown} + | Enum.map(policies, fn p -> {{p.repository, p.name}, Map.get(p, :cooldown)} end) + ]) do + {_source, "0d"} -> nil - duration -> + {source, duration} -> source_str = - case Cooldown.source(local_cooldown, policies) do + case source do :local -> "local" {repo, name} -> "#{repo}/#{name}" end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 796527fb..67d8d4b2 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -85,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) @@ -130,67 +132,6 @@ defmodule Hex.RemoteConverger do end end - defp setup_cooldown(old_lock, locked) do - local = Hex.State.fetch!(:cooldown) - policies = Map.values(Hex.State.fetch!(:policies)) - effective = Hex.Policy.Cooldown.strictest(local, policies) - cutoff = Hex.Cooldown.build_cutoff(effective) - 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, []) - Hex.State.put(:policy_filtered_versions, []) - end - - @doc false - def build_cooldown_locked_versions(old_lock) do - # Maps {repo, package} to the list of versions currently in the lockfile - # for that package. The cooldown filter treats these as exempt: a version - # the user is already running is trusted, even if cooldown would otherwise - # filter it. This lets re-resolution fall back to the locked version when - # no newer eligible candidate exists, instead of failing. - for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), - into: %{} do - {{repo || "hexpm", name}, [to_string(version)]} - end - end - - @doc false - def build_cooldown_bypass(_old_lock, _locked, :disabled), do: MapSet.new() - - def build_cooldown_bypass(old_lock, locked, _cutoff) do - # The bypass set has two sources: - # - # 1. Packages in `locked` (the post-`prepare_locked/3` set): the lockfile - # entry survived requirement checking and dep unlocking, so this - # package is being installed from the lock — no re-resolution, no - # cooldown. Matches the design's "cooldown does not apply when - # proceeding from the lockfile" promise. - # - # 2. Packages in `old_lock` whose locked version is known-unsafe (retired - # or carrying a security advisory): the user re-resolving is trying - # to escape that version; cooldown must not block the escape. Walks - # `old_lock` rather than `locked` because `mix deps.update foo` to - # escape an unsafe foo removes foo from `locked`. - lock_satisfied = for %{name: name} <- locked, into: MapSet.new(), do: name - - unsafe = - for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock), - locked_version_unsafe?(repo || "hexpm", name, to_string(version)), - into: MapSet.new(), - do: name - - MapSet.union(lock_satisfied, unsafe) - end - - defp locked_version_unsafe?(repo, name, version) do - Registry.retired(repo, name, version) != nil or - Registry.advisories(repo, name, version) not in [nil, []] - end - @doc false def print_cooldown_summary() do cutoff = Hex.State.fetch!(:cooldown_cutoff) diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index b1627462..8f27947b 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -1,7 +1,8 @@ defmodule Mix.Tasks.Hex.Policy do use Mix.Task - alias Hex.Policy.{Cooldown, Filter, Sources} + alias Hex.Cooldown + alias Hex.Policy.{Filter, Sources} alias Hex.Policy.Diagnostics @shortdoc "Inspects active Hex dependency policies" @@ -133,13 +134,16 @@ defmodule Mix.Tasks.Hex.Policy do local = Hex.State.fetch!(:cooldown) - case Cooldown.strictest(local, policies) do - "0d" -> + case Cooldown.strictest([ + {:local, local} + | Enum.map(policies, fn p -> {{p.repository, p.name}, Map.get(p, :cooldown)} end) + ]) do + {_source, "0d"} -> :ok - duration -> + {source, duration} -> source_str = - case Cooldown.source(local, policies) do + case source do :local -> "local" {repo, name} -> "#{repo}/#{name}" end 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/policy/cooldown_test.exs b/test/hex/policy/cooldown_test.exs deleted file mode 100644 index e14766e7..00000000 --- a/test/hex/policy/cooldown_test.exs +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Hex.Policy.CooldownTest do - use HexTest.Case - alias Hex.Policy.Cooldown - - describe "strictest/2" do - test "returns the longer of local + each policy duration" do - assert "14d" == - Cooldown.strictest("7d", [ - %{repository: "myorg", name: "p", cooldown: "14d"}, - %{repository: "myorg", name: "q", cooldown: "3d"} - ]) - end - - test "ignores nil policy cooldowns" do - assert "7d" == - Cooldown.strictest("7d", [ - %{repository: "myorg", name: "p", cooldown: nil}, - %{repository: "myorg", name: "q"} - ]) - end - - test "uses policy when local is 0" do - assert "14d" == - Cooldown.strictest("0d", [%{repository: "myorg", name: "p", cooldown: "14d"}]) - end - - test "returns 0d when nothing is set" do - assert "0d" == - Cooldown.strictest("0d", [%{repository: "myorg", name: "p", cooldown: nil}]) - end - - test "handles nil/empty local" do - assert "14d" == - Cooldown.strictest(nil, [%{repository: "myorg", name: "p", cooldown: "14d"}]) - - assert "14d" == - Cooldown.strictest("", [%{repository: "myorg", name: "p", cooldown: "14d"}]) - end - end - - describe "source/2" do - test "names the contributor of the strictest value" do - pol1 = %{repository: "myorg", name: "strict-prod", cooldown: "14d"} - pol2 = %{repository: "acme", name: "baseline", cooldown: "7d"} - assert {"myorg", "strict-prod"} == Cooldown.source("3d", [pol1, pol2]) - end - - test "returns :local when local is strictest" do - assert :local == - Cooldown.source("30d", [%{repository: "myorg", name: "p", cooldown: "7d"}]) - end - - test "returns :local when nothing else contributes" do - assert :local == Cooldown.source("7d", []) - 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 index fa7fd8bc..a40e7576 100644 --- a/test/hex/remote_converger_policy_test.exs +++ b/test/hex/remote_converger_policy_test.exs @@ -8,6 +8,7 @@ defmodule Hex.RemoteConvergerPolicyTest do System.delete_env("HEX_POLICY") try do + Hex.State.refresh() assert {:ok, %{}} = Hex.Policy.load_all() after case original do @@ -17,11 +18,4 @@ defmodule Hex.RemoteConvergerPolicyTest do end end) end - - test "Hex.Policy.Cooldown.strictest folds local + policies" do - assert "14d" == - Hex.Policy.Cooldown.strictest("7d", [ - %{repository: "myorg", name: "p", cooldown: "14d"} - ]) - end end From 41dc83cb66cb63b865dcc6fa61c64a527ba842d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:26:20 +0200 Subject: [PATCH 21/25] Route policy diagnostics through Hex.Utils label helpers The diagnostics module shipped its own severity_label/retirement_label clauses keyed on the int form that Hex.Policy.Filter no longer carries. Delegating to Hex.Utils.advisory_severity/1 and package_retirement_reason/1 keeps blocker output consistent with mix hex.audit and removes the parallel label table. --- lib/hex/policy/diagnostics.ex | 17 ++--------------- test/hex/policy/diagnostics_test.exs | 8 +++++--- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/lib/hex/policy/diagnostics.ex b/lib/hex/policy/diagnostics.ex index 3a413313..c916f24d 100644 --- a/lib/hex/policy/diagnostics.ex +++ b/lib/hex/policy/diagnostics.ex @@ -115,27 +115,14 @@ defmodule Hex.Policy.Diagnostics do 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 ≥ #{severity_label(sev)})" + "#{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: #{retirement_label(r)})" + "#{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 - - defp severity_label(1), do: "low" - defp severity_label(2), do: "medium" - defp severity_label(3), do: "high" - defp severity_label(4), do: "critical" - defp severity_label(other), do: to_string(other) - - defp retirement_label(0), do: "other" - defp retirement_label(1), do: "invalid" - defp retirement_label(2), do: "security" - defp retirement_label(3), do: "deprecated" - defp retirement_label(4), do: "renamed" - defp retirement_label(other), do: to_string(other) end diff --git a/test/hex/policy/diagnostics_test.exs b/test/hex/policy/diagnostics_test.exs index 6537416c..4fbc193b 100644 --- a/test/hex/policy/diagnostics_test.exs +++ b/test/hex/policy/diagnostics_test.exs @@ -25,7 +25,7 @@ defmodule Hex.Policy.DiagnosticsTest do repo: "hexpm", package: "phoenix", version: "1.7.18", - blockers: [%{policy: hd(policies), reason: {:advisory, 3}}] + blockers: [%{policy: hd(policies), reason: {:advisory, :SEVERITY_HIGH}}] } ] @@ -51,19 +51,21 @@ defmodule Hex.Policy.DiagnosticsTest do repo: "hexpm", package: "decimal", version: "2.0.0", - blockers: [%{policy: pol, reason: {:retirement, 2}}] + blockers: [%{policy: pol, reason: {:retirement, :RETIRED_SECURITY}}] }, %{ repo: "hexpm", package: "decimal", version: "2.0.1", - blockers: [%{policy: pol, reason: {:advisory, 3}}] + 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 From 88fd56a609b65d912e586fba9433c44e66b7dc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:27:05 +0200 Subject: [PATCH 22/25] Promote active policy lookup into Hex.Policy.active/0 The hex.policy task carried its own active_policies_or_load helper that read Hex.State, fell back to Hex.Policy.load_all, and cached the result. That belongs next to the loader itself, so callers that need the active set ask Hex.Policy for it directly instead of reimplementing the cache. --- lib/hex/policy.ex | 40 +++++++++++++++++++++++++++++++++++++ lib/mix/tasks/hex.policy.ex | 36 +++------------------------------ 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/lib/hex/policy.ex b/lib/hex/policy.ex index 66ab982b..6b54f72a 100644 --- a/lib/hex/policy.ex +++ b/lib/hex/policy.ex @@ -37,4 +37,44 @@ defmodule Hex.Policy do {: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/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index 8f27947b..b10ed499 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -2,8 +2,7 @@ defmodule Mix.Tasks.Hex.Policy do use Mix.Task alias Hex.Cooldown - alias Hex.Policy.{Filter, Sources} - alias Hex.Policy.Diagnostics + alias Hex.Policy.{Diagnostics, Filter} @shortdoc "Inspects active Hex dependency policies" @@ -72,7 +71,7 @@ defmodule Mix.Tasks.Hex.Policy do end defp show() do - case active_policies_or_load() do + case Hex.Policy.active() do {:ok, policies_map} when map_size(policies_map) > 0 -> render_show(policies_map) @@ -84,35 +83,6 @@ defmodule Mix.Tasks.Hex.Policy do end end - defp active_policies_or_load() do - loaded = Hex.State.fetch!(:policies) - - cond do - loaded != %{} -> - {:ok, loaded} - - configured_refs() == [] -> - {:ok, %{}} - - true -> - case Hex.Policy.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 - defp render_show(policies_map) do policies = Map.values(policies_map) Hex.Shell.info("Active policies (#{length(policies)}):\n") @@ -155,7 +125,7 @@ defmodule Mix.Tasks.Hex.Policy do defp why(arg) do {repo, package} = parse_package_arg(arg) - case active_policies_or_load() do + case Hex.Policy.active() do {:ok, policies_map} when map_size(policies_map) > 0 -> Hex.Registry.Server.open() Hex.Registry.Server.prefetch([{repo, package}]) From 2d91058250f1f63f76dbae3280c7229097c95f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:28:01 +0200 Subject: [PATCH 23/25] Route mix hex.policy labels through Hex.Utils and print_table The task duplicated severity/retirement label tables that already live in Hex.Utils, and rendered why output by ad-hoc string concatenation. Going through the shared helpers and Mix.Tasks.Hex.print_table aligns hex.policy with the rest of the CLI surface (matching hex.audit's uppercase severity labels). --- lib/mix/tasks/hex.policy.ex | 92 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/lib/mix/tasks/hex.policy.ex b/lib/mix/tasks/hex.policy.ex index b10ed499..05c06589 100644 --- a/lib/mix/tasks/hex.policy.ex +++ b/lib/mix/tasks/hex.policy.ex @@ -93,11 +93,11 @@ defmodule Mix.Tasks.Hex.Policy do cooldown = Map.get(p, :cooldown) || "(none)" Hex.Shell.info(" Cooldown: #{cooldown}") - advisory = Map.get(p, :advisory_min_severity) - Hex.Shell.info(" Advisory rule: #{advisory_label(advisory)}") + Hex.Shell.info(" Advisory rule: #{advisory_label(Map.get(p, :advisory_min_severity))}") - retirement = Map.get(p, :retirement_reasons) || [] - Hex.Shell.info(" Retirement rule: #{retirement_label(retirement)}") + Hex.Shell.info( + " Retirement rule: #{retirement_label(Map.get(p, :retirement_reasons) || [])}" + ) Hex.Shell.info("") end) @@ -163,43 +163,36 @@ defmodule Mix.Tasks.Hex.Policy do Hex.Shell.info("Versions of #{inspect(package)} (#{length(versions)}):") Hex.Shell.info("") - Enum.each(versions, fn v -> - version = to_string(v) - release = Filter.release_from_registry(repo, package, version) - - case Filter.classify_set(policies, release) do - :allowed -> - Hex.Shell.info(" #{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(", ") - - Hex.Shell.info(" #{version} blocked by #{blocker_text}") - end - end) - end + 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) - defp format_reason({:advisory, sev}), do: "advisory ≥ #{severity_word(sev)}" - defp format_reason({:retirement, r}), do: "retirement: #{retirement_word(r)}" + Mix.Tasks.Hex.print_table(["Version", "Status", "Blocked by"], rows) + end - defp severity_word(1), do: "low" - defp severity_word(2), do: "medium" - defp severity_word(3), do: "high" - defp severity_word(4), do: "critical" - defp severity_word(n), do: to_string(n) + defp format_reason({:advisory, sev}), + do: "advisory ≥ #{Hex.Utils.advisory_severity(sev)}" - defp retirement_word(0), do: "other" - defp retirement_word(1), do: "invalid" - defp retirement_word(2), do: "security" - defp retirement_word(3), do: "deprecated" - defp retirement_word(4), do: "renamed" - defp retirement_word(n), do: to_string(n) + 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" @@ -207,24 +200,23 @@ defmodule Mix.Tasks.Hex.Policy do defp advisory_label(nil), do: "(disabled)" defp advisory_label(0), do: "block any advisory" - defp advisory_label(1), do: "block ≥ LOW" - defp advisory_label(2), do: "block ≥ MEDIUM" - defp advisory_label(3), do: "block ≥ HIGH" - defp advisory_label(4), do: "block ≥ CRITICAL" + + 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(&reason_name/1) + |> 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 - - defp reason_name(0), do: "OTHER" - defp reason_name(1), do: "INVALID" - defp reason_name(2), do: "SECURITY" - defp reason_name(3), do: "DEPRECATED" - defp reason_name(4), do: "RENAMED" - defp reason_name(other), do: to_string(other) end From 26a4f85b86114cbaca011302c3b3330ee3fafdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:29:46 +0200 Subject: [PATCH 24/25] Read policy refs through Hex.State Hex.Policy.Sources ran its own project/env/global config plumbing in parallel to Hex.State. Splitting the policy key into project / env / global entries and adding a config_scope flag lets Hex.State own parsing and source attribution, so Sources.load_all/0 is now just a union over three state lookups. --- lib/hex/policy/sources.ex | 39 +++++++-------------- lib/hex/state.ex | 28 +++++++++++++-- test/hex/policy/sources_test.exs | 58 +++++++++----------------------- 3 files changed, 54 insertions(+), 71 deletions(-) diff --git a/lib/hex/policy/sources.ex b/lib/hex/policy/sources.ex index 6905324b..87aca947 100644 --- a/lib/hex/policy/sources.ex +++ b/lib/hex/policy/sources.ex @@ -5,39 +5,24 @@ defmodule Hex.Policy.Sources do @doc """ Reads policy refs from all three sources independently and unions them, - deduplicated. Order: project (`mix.exs`) first, then env (`HEX_POLICY`), + 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 - with {:ok, project} <- parse_config(read_project()), - {:ok, env} <- parse_config(read_env()), - {:ok, global} <- parse_config(read_global()) do - {:ok, dedup(project ++ env ++ global)} - else - _ -> :error - end - end - - defp read_project() do - Mix.Project.config() - |> Keyword.get(:hex, []) - |> Keyword.get(:policy, []) - end - - defp read_env() do - case System.get_env("HEX_POLICY") do - nil -> [] - "" -> [] - value -> value - end - end - - defp read_global() do - Hex.Config.read() - |> Keyword.get(:policy, []) + refs = + Hex.State.fetch!(:policy_env) ++ + Hex.State.fetch!(:policy_project) ++ + Hex.State.fetch!(:policy_global) + + {:ok, dedup(refs)} + rescue + _ -> :error end @doc """ diff --git a/lib/hex/state.ex b/lib/hex/state.ex index 66ed1e09..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} } } @@ -251,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} @@ -295,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/test/hex/policy/sources_test.exs b/test/hex/policy/sources_test.exs index 990bcf8c..0bff84bd 100644 --- a/test/hex/policy/sources_test.exs +++ b/test/hex/policy/sources_test.exs @@ -60,51 +60,25 @@ defmodule Hex.Policy.SourcesTest do end describe "load_all/0" do - setup do - original = System.get_env("HEX_POLICY") - System.delete_env("HEX_POLICY") - - on_exit(fn -> - case original do - nil -> System.delete_env("HEX_POLICY") - value -> System.put_env("HEX_POLICY", value) - end - end) - - :ok - end - test "returns the empty set when no source contributes" do - in_tmp("policy_sources_empty", fn -> - Hex.State.put(:config_home, File.cwd!()) - assert {:ok, []} == Sources.load_all() - end) - end - - test "unions and dedups refs across env and global config" do - in_tmp("policy_sources_union", fn -> - Hex.State.put(:config_home, File.cwd!()) - Hex.Config.update(policy: "acme/baseline,myorg/strict") - - System.put_env("HEX_POLICY", "myorg/strict,acme/extra") - - assert {:ok, refs} = Sources.load_all() - - # Env entries come before global entries; dedup keeps first-seen. - assert refs == [ - {"myorg", "strict"}, - {"acme", "extra"}, - {"acme", "baseline"} - ] - end) + Hex.State.put(:policy_env, []) + Hex.State.put(:policy_project, []) + Hex.State.put(:policy_global, []) + assert {:ok, []} == Sources.load_all() end - test "returns :error when any source is malformed" do - in_tmp("policy_sources_error", fn -> - Hex.State.put(:config_home, File.cwd!()) - System.put_env("HEX_POLICY", "garbage-without-slash") - assert :error == 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 From 6e7e4345ef2afaba7725f4b98099002c1d72e77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 23 May 2026 00:32:28 +0200 Subject: [PATCH 25/25] Read repo organization from the config map in Hex.Repo.get_policy/3 The function carried its own \"hexpm:\" <> org parser and a suffix-strip of the cached URL to recover the organization. default_organization/3 already builds the config map with everything else derived from the parent repo, so stashing the organization name there at construction time turns get_policy/3 into a simple Map.get lookup. --- lib/hex/repo.ex | 25 ++++++++++++------------ test/hex/registry/server_policy_test.exs | 3 ++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index 6fa3cc39..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 @@ -235,23 +236,23 @@ defmodule Hex.Repo do 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 \\ nil) do + def get_policy(repo, name, etag) do repo_config = get_repo(repo) - organization = repo_organization(repo) config = build_hex_core_config(repo_config, repo, etag) - config = put_repo_organization(config, repo_config, organization) + config = put_repo_organization(config, repo_config) :mix_hex_repo.get_policy(config, name) end - defp repo_organization("hexpm:" <> organization), do: organization - defp repo_organization(_), do: nil - - defp put_repo_organization(config, _repo_config, nil), do: config + defp put_repo_organization(config, repo_config) do + case Map.get(repo_config, :organization) do + nil -> + config - defp put_repo_organization(config, repo_config, organization) do - suffix = "/repos/#{organization}" - base_url = String.replace_suffix(repo_config.url, suffix, "") - %{config | repo_url: base_url, repo_organization: organization} + 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 diff --git a/test/hex/registry/server_policy_test.exs b/test/hex/registry/server_policy_test.exs index 98bdd134..742bc1f1 100644 --- a/test/hex/registry/server_policy_test.exs +++ b/test/hex/registry/server_policy_test.exs @@ -12,7 +12,8 @@ defmodule Hex.Registry.ServerPolicyTest do public_key: File.read!(fixture_path("test_pub.pem")), auth_key: "key", trusted: true, - oauth_exchange: false + oauth_exchange: false, + organization: "myorg" }) Hex.State.put(:repos, repos)