Skip to content

Commit 6a523f3

Browse files
committed
Apply IEEE 754 decimal128 defaults
Builds on the v2.4.0 mitigations for CVE-2026-32686 by making the secure limits the default at every public boundary instead of opt-in, and aligns Decimal.Context with IEEE 754 decimal128. * Decimal.Context defaults: precision 28 -> 34, emax :infinity -> 6_144, emin :infinity -> -6_143 * parse/1, parse/2, cast/1, cast/2 default max_digits: 34, max_exponent: 6_144 - pass :infinity to opt out * to_string/2, to_string/3 default max_digits: 6_178 (precision + emax, worst-case :normal width for any in-range decimal128 value) * Inspect, String.Chars, JSON.Encoder pass max_digits: :infinity so debug output always succeeds * Refactored to_string/2 clauses to a private do_to_string/2; the public arities apply default bounds and delegate * Removed the now-unused unbounded parse_unsign/1 clauses Tests that exercised out-of-range exponents updated to set emax/emin explicitly or construct via Decimal.new/3 instead of parse.
1 parent 9c361f8 commit 6a523f3

4 files changed

Lines changed: 157 additions & 187 deletions

File tree

lib/decimal.ex

Lines changed: 81 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,24 @@ defmodule Decimal do
3535
10 ^ exponent` and will refer to the sign in documentation as either *positive*
3636
or *negative*.
3737
38-
By default there is no maximum or minimum value for the exponent because
39-
`Decimal.Context` defaults `emax` and `emin` to `:infinity`. Because of that
40-
all numbers are "normal". This means that when an operation should, according
41-
to the specification, return a number that "underflows" 0 is returned instead
42-
of Etiny. This may happen when dividing a number with infinity. When `emax`
43-
or `emin` are finite, overflow and underflow may be signalled. Clamped is
44-
still not signalled.
38+
The default `Decimal.Context` follows IEEE 754 decimal128: `precision` is
39+
34, `emax` is 6 144, and `emin` is -6 143. Operation results whose adjusted
40+
exponent leaves that band signal overflow or underflow. Clamped is still
41+
not signalled.
4542
4643
## Large exponents and untrusted input
4744
4845
Decimal can represent compact values with very large exponents, such as
4946
`1e1000000`. These values are valid decimals, but some APIs may need memory
50-
or CPU proportional to the expanded size of the number. This is especially
51-
important for decimals parsed from user input, JSON payloads, form fields,
52-
database fields, or other external data.
47+
or CPU proportional to the expanded size of the number.
5348
54-
Use `parse/2` or `cast/2` with `:max_digits` and `:max_exponent` when parsing
55-
untrusted input. Use `to_string/3` with `:max_digits` when rendering output
56-
formats that may expand the exponent, such as `:normal` or `:xsd`.
57-
Finite `Decimal.Context` `emax` and `emin` values can limit operation
58-
results, but they do not validate already-created decimals and should not
59-
replace parse/cast limits for untrusted input.
49+
`parse/1`, `parse/2`, `cast/1`, `cast/2`, `to_string/2`, and `to_string/3`
50+
apply IEEE 754 decimal128 limits by default: `:max_digits` of 34,
51+
`:max_exponent` of 6 144, and a `:max_digits` for output of 6 178
52+
(precision + emax — large enough to render any in-range decimal128 in any
53+
format). These defaults reject the pathological inputs described in
54+
CVE-2026-32686 without materializing them. Pass options on the explicit
55+
arities to override; pass `:infinity` to disable a limit entirely.
6056
6157
## Protocol Implementations
6258
@@ -164,6 +160,14 @@ defmodule Decimal do
164160
@type to_string_option ::
165161
{:max_digits, non_neg_integer | :infinity}
166162

163+
# IEEE 754 decimal128 defaults: precision = 34, emax = 6_144, emin = -6_143.
164+
# The to_string default is precision + emax (34 + 6_144), which is the
165+
# worst-case `:normal` digit-character count for any in-range decimal128
166+
# value.
167+
@default_max_digits 34
168+
@default_max_exponent 6_144
169+
@default_to_string_max_digits 6_178
170+
167171
# Below 10^2000 the BIF `:erlang.integer_to_binary/1` is fast enough; for
168172
# larger integers `integer_to_decimal_iodata/3` recursively splits on a
169173
# power of 10 (down to chunks of `@decimal_conversion_leaf_digits` digits)
@@ -1538,18 +1542,7 @@ defmodule Decimal do
15381542
15391543
"""
15401544
@spec cast(term) :: {:ok, t} | :error
1541-
def cast(integer) when is_integer(integer), do: {:ok, Decimal.new(integer)}
1542-
def cast(%Decimal{} = decimal), do: {:ok, decimal}
1543-
def cast(float) when is_float(float), do: {:ok, from_float(float)}
1544-
1545-
def cast(binary) when is_binary(binary) do
1546-
case parse(binary) do
1547-
{decimal, ""} -> {:ok, decimal}
1548-
_ -> :error
1549-
end
1550-
end
1551-
1552-
def cast(_), do: :error
1545+
def cast(term), do: cast_with_limits(term, default_parse_limits())
15531546

15541547
@doc """
15551548
Creates a new decimal number from an integer, string, float, or existing decimal
@@ -1560,8 +1553,10 @@ defmodule Decimal do
15601553
doc_since("2.4.0")
15611554
@spec cast(term, [parse_option]) :: {:ok, t} | :error
15621555
def cast(term, opts) when is_list(opts) do
1563-
limits = parse_limits!(opts)
1556+
cast_with_limits(term, parse_limits!(opts))
1557+
end
15641558

1559+
defp cast_with_limits(term, limits) do
15651560
cond do
15661561
is_integer(term) ->
15671562
decimal = Decimal.new(term)
@@ -1591,6 +1586,10 @@ defmodule Decimal do
15911586
If successful, returns a tuple in the form of `{decimal, remainder_of_binary}`,
15921587
otherwise `:error`.
15931588
1589+
Inputs whose digit count or exponent magnitude exceed the default limits
1590+
(`#{@default_max_digits}` digits, `#{@default_max_exponent}` absolute
1591+
exponent) return `:error`. Use `parse/2` to override the limits.
1592+
15941593
## Examples
15951594
15961595
iex> Decimal.parse("3.14")
@@ -1607,34 +1606,21 @@ defmodule Decimal do
16071606
16081607
"""
16091608
@spec parse(binary()) :: {t(), binary()} | :error
1610-
def parse("+" <> rest) do
1611-
parse_unsign(rest)
1612-
end
1613-
1614-
def parse("-" <> rest) do
1615-
case parse_unsign(rest) do
1616-
{%Decimal{} = num, rest} -> {%{num | sign: -1}, rest}
1617-
:error -> :error
1618-
end
1619-
end
1620-
16211609
def parse(binary) when is_binary(binary) do
1622-
parse_unsign(binary)
1610+
parse_with_limits(binary, default_parse_limits())
16231611
end
16241612

16251613
@doc """
1626-
Parses a binary into a decimal with optional limits.
1627-
1628-
Use this function instead of `parse/1` for untrusted input. Without explicit
1629-
limits, decimal parsing accepts any exponent and digit count for backwards
1630-
compatibility.
1614+
Parses a binary into a decimal with explicit limits.
16311615
16321616
The following options are supported:
16331617
16341618
* `:max_digits` - maximum number of decimal digits consumed from the input,
1635-
including leading and trailing zeros.
1619+
including leading and trailing zeros. Defaults to `#{@default_max_digits}`.
1620+
Pass `:infinity` to disable.
16361621
* `:max_exponent` - maximum absolute value of the parsed decimal exponent,
1637-
after fractional digits are accounted for.
1622+
after fractional digits are accounted for. Defaults to
1623+
`#{@default_max_exponent}`. Pass `:infinity` to disable.
16381624
16391625
Returns `:error` when a parsed number exceeds the configured limits.
16401626
"""
@@ -1663,10 +1649,11 @@ defmodule Decimal do
16631649
@doc """
16641650
Converts given number to its string representation.
16651651
1666-
The default `:scientific` format is compact for large positive exponents.
1667-
The `:normal` and `:xsd` formats may allocate output proportional to the
1668-
expanded size of the decimal. Use `to_string/3` with `:max_digits` when
1669-
rendering decimals from untrusted input.
1652+
Output is bounded to `#{@default_to_string_max_digits}` digit characters by
1653+
default; pass options via `to_string/3` to override. `:scientific` is compact
1654+
for large positive exponents and rarely hits the limit; `:normal` and `:xsd`
1655+
expand proportional to the exponent and will raise `ArgumentError` when the
1656+
limit would be exceeded.
16701657
16711658
## Options
16721659
@@ -1696,15 +1683,21 @@ defmodule Decimal do
16961683
@spec to_string(t, :scientific | :normal | :xsd | :raw) :: String.t()
16971684
def to_string(num, type \\ :scientific)
16981685

1699-
def to_string(%Decimal{sign: sign, coef: :NaN}, _type) do
1686+
def to_string(%Decimal{} = num, type)
1687+
when type in [:scientific, :normal, :xsd, :raw] do
1688+
check_to_string_max_digits!(num, type, @default_to_string_max_digits)
1689+
do_to_string(num, type)
1690+
end
1691+
1692+
defp do_to_string(%Decimal{sign: sign, coef: :NaN}, _type) do
17001693
if sign == 1, do: "NaN", else: "-NaN"
17011694
end
17021695

1703-
def to_string(%Decimal{sign: sign, coef: :inf}, _type) do
1696+
defp do_to_string(%Decimal{sign: sign, coef: :inf}, _type) do
17041697
if sign == 1, do: "Infinity", else: "-Infinity"
17051698
end
17061699

1707-
def to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :normal) do
1700+
defp do_to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :normal) do
17081701
digits = integer_to_decimal_binary(coef)
17091702
length = byte_size(digits)
17101703

@@ -1725,7 +1718,7 @@ defmodule Decimal do
17251718
IO.iodata_to_binary(iodata)
17261719
end
17271720

1728-
def to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :scientific) do
1721+
defp do_to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :scientific) do
17291722
digits = integer_to_decimal_binary(coef)
17301723
length = byte_size(digits)
17311724
adjusted = exp + length - 1
@@ -1762,16 +1755,16 @@ defmodule Decimal do
17621755
IO.iodata_to_binary(iodata)
17631756
end
17641757

1765-
def to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :raw) do
1758+
defp do_to_string(%Decimal{sign: sign, coef: coef, exp: exp}, :raw) do
17661759
str = integer_to_decimal_binary(coef)
17671760
str = if sign == -1, do: [?- | str], else: str
17681761
str = if exp != 0, do: [str, "E", :erlang.integer_to_binary(exp)], else: str
17691762

17701763
IO.iodata_to_binary(str)
17711764
end
17721765

1773-
def to_string(%Decimal{} = decimal, :xsd) do
1774-
decimal |> canonical_xsd() |> to_string(:normal)
1766+
defp do_to_string(%Decimal{} = decimal, :xsd) do
1767+
decimal |> canonical_xsd() |> do_to_string(:normal)
17751768
end
17761769

17771770
defp zeroes(0), do: ""
@@ -1845,25 +1838,25 @@ defmodule Decimal do
18451838
defp byte_bit_length(_byte), do: 1
18461839

18471840
@doc """
1848-
Converts given number to its string representation with optional limits.
1849-
1850-
Use this function when rendering decimals from untrusted input, especially
1851-
with `:normal` or `:xsd`, because those formats may otherwise allocate output
1852-
proportional to the expanded size of the decimal.
1841+
Converts given number to its string representation with explicit limits.
18531842
18541843
The following options are supported:
18551844
18561845
* `:max_digits` - maximum number of digit characters in the output. Sign,
1857-
decimal point, and exponent markers are not counted.
1846+
decimal point, and exponent markers are not counted. Defaults to
1847+
`#{@default_to_string_max_digits}`. Pass `:infinity` to disable.
18581848
18591849
Raises `ArgumentError` when the configured limit would be exceeded.
18601850
"""
18611851
doc_since("2.4.0")
18621852
@spec to_string(t, :scientific | :normal | :xsd | :raw, [to_string_option]) :: String.t()
1863-
def to_string(%Decimal{} = num, type, opts) when is_list(opts) do
1864-
max_digits = limit!(:max_digits, Keyword.get(opts, :max_digits, :infinity))
1853+
def to_string(%Decimal{} = num, type, opts)
1854+
when is_list(opts) and type in [:scientific, :normal, :xsd, :raw] do
1855+
max_digits =
1856+
limit!(:max_digits, Keyword.get(opts, :max_digits, @default_to_string_max_digits))
1857+
18651858
check_to_string_max_digits!(num, type, max_digits)
1866-
to_string(num, type)
1859+
do_to_string(num, type)
18671860
end
18681861

18691862
defp canonical_xsd(%Decimal{coef: 0} = decimal), do: %{decimal | exp: -1}
@@ -2566,16 +2559,24 @@ defmodule Decimal do
25662559
## PARSING ##
25672560

25682561
defp parse_limits!(opts) do
2569-
Enum.reduce(opts, %{max_digits: :infinity, max_exponent: :infinity}, fn
2570-
{:max_digits, value}, acc ->
2571-
%{acc | max_digits: limit!(:max_digits, value)}
2572-
2573-
{:max_exponent, value}, acc ->
2574-
%{acc | max_exponent: limit!(:max_exponent, value)}
2562+
Enum.reduce(
2563+
opts,
2564+
%{max_digits: @default_max_digits, max_exponent: @default_max_exponent},
2565+
fn
2566+
{:max_digits, value}, acc ->
2567+
%{acc | max_digits: limit!(:max_digits, value)}
2568+
2569+
{:max_exponent, value}, acc ->
2570+
%{acc | max_exponent: limit!(:max_exponent, value)}
2571+
2572+
{key, _value}, _acc ->
2573+
raise ArgumentError, "unknown option #{inspect(key)}"
2574+
end
2575+
)
2576+
end
25752577

2576-
{key, _value}, _acc ->
2577-
raise ArgumentError, "unknown option #{inspect(key)}"
2578-
end)
2578+
defp default_parse_limits do
2579+
%{max_digits: @default_max_digits, max_exponent: @default_max_exponent}
25792580
end
25802581

25812582
defp limit!(_key, :infinity), do: :infinity
@@ -2587,66 +2588,12 @@ defmodule Decimal do
25872588
"#{inspect(key)} must be a non-negative integer or :infinity, got: #{inspect(value)}"
25882589
end
25892590

2590-
defp parse_unsign(<<first, remainder::size(7)-binary, rest::binary>>) when first in [?i, ?I] do
2591-
if String.downcase(remainder) == "nfinity" do
2592-
{%Decimal{coef: :inf}, rest}
2593-
else
2594-
:error
2595-
end
2596-
end
2597-
2598-
defp parse_unsign(<<first, remainder::size(2)-binary, rest::binary>>) when first in [?i, ?I] do
2599-
if String.downcase(remainder) == "nf" do
2600-
{%Decimal{coef: :inf}, rest}
2601-
else
2602-
:error
2603-
end
2604-
end
2605-
2606-
defp parse_unsign(<<first, remainder::size(2)-binary, rest::binary>>) when first in [?n, ?N] do
2607-
if String.downcase(remainder) == "an" do
2608-
{%Decimal{coef: :NaN}, rest}
2609-
else
2610-
:error
2611-
end
2612-
end
2613-
2614-
defp parse_unsign(bin) do
2615-
{int_rev, int_size, after_int} = parse_digits_count(bin, [], 0)
2616-
2617-
case after_int do
2618-
<<?., after_dot::binary>> ->
2619-
{coef_rev, total_size, after_float} = parse_digits_count(after_dot, int_rev, int_size)
2620-
2621-
if total_size == 0 do
2622-
:error
2623-
else
2624-
{exp, rest} = parse_exp(after_float)
2625-
coef = digits_acc_to_integer(coef_rev, total_size)
2626-
float_size = total_size - int_size
2627-
{%Decimal{coef: coef, exp: parse_exp_int(exp) - float_size}, rest}
2628-
end
2629-
2630-
_ ->
2631-
if int_size == 0 do
2632-
:error
2633-
else
2634-
{exp, rest} = parse_exp(after_int)
2635-
coef = digits_acc_to_integer(int_rev, int_size)
2636-
{%Decimal{coef: coef, exp: parse_exp_int(exp)}, rest}
2637-
end
2638-
end
2639-
end
2640-
26412591
defp parse_digits_count(<<digit, rest::binary>>, acc, count) when digit in ?0..?9 do
26422592
parse_digits_count(rest, [digit | acc], count + 1)
26432593
end
26442594

26452595
defp parse_digits_count(rest, acc, count), do: {acc, count, rest}
26462596

2647-
defp parse_exp_int([]), do: 0
2648-
defp parse_exp_int(chars), do: List.to_integer(chars)
2649-
26502597
defp digits_acc_to_integer([], _size), do: 0
26512598
defp digits_acc_to_integer(acc, _size), do: :erlang.list_to_integer(:lists.reverse(acc))
26522599

@@ -2876,21 +2823,21 @@ end
28762823

28772824
defimpl Inspect, for: Decimal do
28782825
def inspect(dec, _opts) do
2879-
"Decimal.new(\"" <> Decimal.to_string(dec) <> "\")"
2826+
"Decimal.new(\"" <> Decimal.to_string(dec, :scientific, max_digits: :infinity) <> "\")"
28802827
end
28812828
end
28822829

28832830
defimpl String.Chars, for: Decimal do
28842831
def to_string(dec) do
2885-
Decimal.to_string(dec)
2832+
Decimal.to_string(dec, :scientific, max_digits: :infinity)
28862833
end
28872834
end
28882835

28892836
# TODO: remove when we require Elixir 1.18
28902837
if Code.ensure_loaded?(JSON.Encoder) and function_exported?(JSON.Encoder, :encode, 2) do
28912838
defimpl JSON.Encoder, for: Decimal do
28922839
def encode(decimal, _encoder) do
2893-
[?", Decimal.to_string(decimal), ?"]
2840+
[?", Decimal.to_string(decimal, :scientific, max_digits: :infinity), ?"]
28942841
end
28952842
end
28962843
end

0 commit comments

Comments
 (0)