%% SPDX-License-Identifier: Apache-2.0 %% SPDX-FileCopyrightText: 2026 Erlang Ecosystem Foundation %% Common utilities for CVSS calculations. -module(cvss_common). -moduledoc false. -export([ round_v1_v2/1, roundup/1, score_to_rating/1, d/1, to_float/1, dmin/2, dmax/2, dpow/2, parse_metrics/2, check_required/2, encode_optional/2, valid_optional/2, strip_parens/1 ]). -include("internal/decimal.hrl"). -doc """ Convert a number to a decimal value. ```erlang > cvss_common:d(0.85). {85, -2} ``` """. -spec d(number() | binary()) -> cvss_decimal:decimal(). d(Value) when is_float(Value) -> cvss_decimal:to_decimal(Value, ?DECIMAL_OPTS); d(Value) when is_integer(Value) -> cvss_decimal:to_decimal(Value, ?DECIMAL_OPTS); d(Value) when is_binary(Value) -> cvss_decimal:to_decimal(Value, ?DECIMAL_OPTS). -doc """ Convert a decimal value to a float. """. -spec to_float(cvss_decimal:decimal()) -> float(). to_float({0, _}) -> 0.0; to_float(D) -> Bin = cvss_decimal:to_binary(D), case binary:match(Bin, <<".">>) of nomatch -> float(binary_to_integer(Bin)); _ -> binary_to_float(Bin) end. -doc """ Round to 1 decimal place using "round half up" (CVSS 1.0/2.0). Uses decimal arithmetic for precision. ```erlang > cvss_common:round_v1_v2(cvss_common:d(6.6555)). {67, -1} > cvss_common:round_v1_v2(cvss_common:d(3.14)). {31, -1} > cvss_common:round_v1_v2(cvss_common:d(3.15)). {32, -1} ``` """. -spec round_v1_v2(cvss_decimal:decimal()) -> cvss_decimal:decimal(). round_v1_v2(Value) -> cvss_decimal:round(round_half_up, Value, 1). -doc """ Round up to 1 decimal place (CVSS 3.x). Uses decimal arithmetic for precision. ```erlang > cvss_common:roundup(cvss_common:d(4.02)). {41, -1} > cvss_common:roundup(cvss_common:d(4.0)). {40, -1} > cvss_common:roundup(cvss_common:d(4.001)). {41, -1} ``` """. -spec roundup(cvss_decimal:decimal()) -> cvss_decimal:decimal(). roundup(Value) -> %% "Round up" means: if there's any fractional part beyond 1 decimal, %% round up to next 0.1. This is ceiling at 1 decimal place. Rounded = cvss_decimal:round(round_down, Value, 1), case cvss_decimal:cmp(Value, Rounded, ?DECIMAL_OPTS) of 0 -> Rounded; 1 -> cvss_decimal:add(Rounded, {1, -1}); -1 -> Rounded end. -doc "Return the smaller of two decimals.". -spec dmin(cvss_decimal:decimal(), cvss_decimal:decimal()) -> cvss_decimal:decimal(). dmin(A, B) -> case cvss_decimal:cmp(A, B, ?DECIMAL_OPTS) of 1 -> B; _ -> A end. -doc "Return the larger of two decimals.". -spec dmax(cvss_decimal:decimal(), cvss_decimal:decimal()) -> cvss_decimal:decimal(). dmax(A, B) -> case cvss_decimal:cmp(A, B, ?DECIMAL_OPTS) of -1 -> B; _ -> A end. -doc """ Convert a CVSS score to a severity rating. Thresholds are the same for all CVSS versions. ```erlang > cvss_common:score_to_rating(0.0). none > cvss_common:score_to_rating(2.5). low > cvss_common:score_to_rating(5.0). medium > cvss_common:score_to_rating(7.5). high > cvss_common:score_to_rating(9.5). critical ``` """. -spec score_to_rating(cvss:score()) -> cvss:severity(). score_to_rating(Score) when Score == 0.0; Score == 0 -> none; score_to_rating(Score) when Score >= 0.1, Score =< 3.9 -> low; score_to_rating(Score) when Score >= 4.0, Score =< 6.9 -> medium; score_to_rating(Score) when Score >= 7.0, Score =< 8.9 -> high; score_to_rating(Score) when Score >= 9.0, Score =< 10.0 -> critical. -doc "Decimal integer power: Base^N for non-negative integer N.". -spec dpow(cvss_decimal:decimal(), non_neg_integer()) -> cvss_decimal:decimal(). dpow(_Base, 0) -> d(1); dpow(Base, N) when N > 0 -> dpow(Base, N, d(1)). dpow(_Base, 0, Acc) -> Acc; dpow(Base, N, Acc) -> dpow(Base, N - 1, cvss_decimal:mult(Acc, Base)). -doc """ Parse a list of `Key:Value` binary pairs into a map using the given parse function. """. -spec parse_metrics([binary()], ParseFun) -> {ok, map()} | {error, term()} when ParseFun :: fun((binary(), binary()) -> {ok, {atom(), term()}} | {error, term()}). parse_metrics([], _ParseFun) -> {ok, #{}}; parse_metrics(Parts, ParseFun) -> parse_metrics(Parts, ParseFun, #{}). parse_metrics([], _ParseFun, Acc) -> {ok, Acc}; parse_metrics([Pair | Rest], ParseFun, Acc) -> case binary:split(Pair, <<":">>) of [Key, Value] -> case ParseFun(Key, Value) of {ok, {AtomKey, AtomValue}} -> case maps:is_key(AtomKey, Acc) of true -> {error, {duplicate_metric, Key}}; false -> parse_metrics(Rest, ParseFun, Acc#{AtomKey => AtomValue}) end; {error, _} = Error -> Error end; _ -> {error, malformed_vector} end. -doc """ Check that all required keys are present in the metrics map. """. -spec check_required([atom()], map()) -> ok | {error, {missing_required_metric, atom()}}. check_required([], _Metrics) -> ok; check_required([Key | Rest], Metrics) -> case maps:is_key(Key, Metrics) of true -> check_required(Rest, Metrics); false -> {error, {missing_required_metric, Key}} end. -doc """ Encode optional metrics, skipping any whose value is in `SkipValues`. """. -spec encode_optional([{iodata(), term(), fun((term()) -> iodata())}], [term()]) -> iolist(). encode_optional(Metrics, SkipValues) -> lists:filtermap( fun({Key, Value, Encoder}) -> case lists:member(Value, SkipValues) of true -> false; false -> {true, [<<$/>>, Key, <<":">>, Encoder(Value)]} end end, Metrics ). -doc """ Check whether an optional metric value is valid. Returns `true` if the value is `undefined` or a member of `ValidValues`. """. -spec valid_optional(term(), [term()]) -> boolean(). valid_optional(undefined, _ValidValues) -> true; valid_optional(Value, ValidValues) -> lists:member(Value, ValidValues). -doc """ Strip optional surrounding parentheses from a binary. """. -spec strip_parens(binary()) -> binary(). strip_parens(<<"(", Rest/binary>>) -> case binary:last(Rest) of $) -> binary:part(Rest, 0, byte_size(Rest) - 1); _ -> Rest end; strip_parens(Vector) -> Vector.