From 0485e027a7f8cf1f86d77d62978b7dd46495e6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 8 May 2026 17:16:25 -0700 Subject: [PATCH 1/2] Don't count leading zeros toward parse :max_digits Inspect renders 34-digit coefficients with negative exponents in the [-6, 0] adjusted range using fixed-point form, prepending "0." (e.g. "0.3162277660168379331998893544432719"). The leading zero pushed the parsed digit count one past the default precision of 34, so the inspect output failed to round-trip through Decimal.parse/Decimal.new. Check the parsed coefficient's significant digit count instead, matching what decimal_within_limits?/2 already does for non-string casts. Closes #231 --- CHANGELOG.md | 9 +++++++++ lib/decimal.ex | 22 +++++++++------------- test/decimal/property_test.exs | 15 +++++++++++++++ test/decimal_test.exs | 21 ++++++++++++++++++--- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa1a67..940c0b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## Unreleased + +### Bug fixes + +* Fix `Decimal.parse/2` and `Decimal.new/2` rejecting inspect output for + values at the context's full precision with negative exponents (e.g. + `Decimal.new("0.3162277660168379331998893544432719")`). The + `:max_digits` limit no longer counts non-significant leading zeros. + ## v3.1.0 (2026-05-08) ### Enhancements diff --git a/lib/decimal.ex b/lib/decimal.ex index 79d95ee..7622ecd 100644 --- a/lib/decimal.ex +++ b/lib/decimal.ex @@ -1617,9 +1617,9 @@ defmodule Decimal do The following options are supported: - * `:max_digits` - maximum number of decimal digits consumed from the input, - including leading and trailing zeros. Defaults to `#{@default_max_digits}`. - Pass `:infinity` to disable. + * `:max_digits` - maximum number of significant decimal digits in the parsed + coefficient. Leading zeros are not counted, but trailing zeros are. Defaults + to `#{@default_max_digits}`. Pass `:infinity` to disable. * `:max_exponent` - maximum absolute value of the parsed decimal exponent, after fractional digits are accounted for. Defaults to `#{@default_max_exponent}`. Pass `:infinity` to disable. @@ -2658,21 +2658,17 @@ defmodule Decimal do total_size == 0 -> :error - exceeds_limit?(total_size, limits.max_digits) -> - :error - true -> {exp, rest} = parse_exp(after_float) exp_chars = if exp == [], do: ~c"0", else: exp float_size = total_size - int_size - case bounded_exponent(exp_chars, float_size, limits.max_exponent) do - {:ok, exp_int} -> - coef = digits_acc_to_integer(coef_rev, total_size) - {%Decimal{coef: coef, exp: exp_int}, rest} - - :error -> - :error + with {:ok, exp_int} <- bounded_exponent(exp_chars, float_size, limits.max_exponent), + coef = digits_acc_to_integer(coef_rev, total_size), + false <- exceeds_limit?(decimal_digit_count(coef), limits.max_digits) do + {%Decimal{coef: coef, exp: exp_int}, rest} + else + _ -> :error end end end diff --git a/test/decimal/property_test.exs b/test/decimal/property_test.exs index dea15ec..1e6857a 100644 --- a/test/decimal/property_test.exs +++ b/test/decimal/property_test.exs @@ -205,6 +205,21 @@ defmodule Decimal.PropertyTest do end end + property "inspect output parses back at default decimal128 limits" do + gen = + decimal( + coef_max: 9_999_999_999_999_999_999_999_999_999_999_999, + exp_min: -6144, + exp_max: 6144 + ) + + check all(a <- gen, max_runs: 200) do + s = Decimal.to_string(a, :scientific, max_digits: :infinity) + assert {parsed, ""} = Decimal.parse(s) + assert parsed == a + end + end + property "Decimal.new/1 of an integer round-trips through to_integer/1" do check all(n <- StreamData.integer(), max_runs: 100) do d = Decimal.new(n) diff --git a/test/decimal_test.exs b/test/decimal_test.exs index cae853e..34e2f1c 100644 --- a/test/decimal_test.exs +++ b/test/decimal_test.exs @@ -91,6 +91,12 @@ defmodule DecimalTest do assert Decimal.parse("123", max_digits: 3) == {d(1, 123, 0), ""} assert Decimal.parse("123", max_digits: 2) == :error + assert Decimal.parse("0.123", max_digits: 3) == {d(1, 123, -3), ""} + assert Decimal.parse("00123", max_digits: 3) == {d(1, 123, 0), ""} + assert Decimal.parse("0.00123", max_digits: 3) == {d(1, 123, -5), ""} + assert Decimal.parse("123.000", max_digits: 6) == {d(1, 123_000, -3), ""} + assert Decimal.parse("123.000", max_digits: 5) == :error + assert Decimal.parse("1e10", max_exponent: 10) == {d(1, 1, 10), ""} assert Decimal.parse("1e10", max_exponent: 9) == :error @@ -146,10 +152,19 @@ defmodule DecimalTest do fractional = "0." <> digits - assert Decimal.parse(fractional, max_digits: 50_001, max_exponent: :infinity) == - {%Decimal{coef: :erlang.binary_to_integer("0" <> digits), exp: -50_000}, ""} + assert Decimal.parse(fractional, max_digits: 50_000, max_exponent: :infinity) == + {%Decimal{coef: :erlang.binary_to_integer(digits), exp: -50_000}, ""} + + assert Decimal.parse(fractional, max_digits: 49_999, max_exponent: :infinity) == :error + end + + test "parse/1 round-trips inspect output at default precision" do + decimal = %Decimal{coef: 3_162_277_660_168_379_331_998_893_544_432_719, exp: -34} + string = Decimal.to_string(decimal, :scientific, max_digits: :infinity) - assert Decimal.parse(fractional, max_digits: 50_000, max_exponent: :infinity) == :error + assert string == "0.3162277660168379331998893544432719" + assert Decimal.parse(string) == {decimal, ""} + assert Decimal.new(string) == decimal end test "nan?/1" do From d56dcf4fa900db7dd32a5fc60dec4e866cafa2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 8 May 2026 17:30:17 -0700 Subject: [PATCH 2/2] Bail before materializing coefficient for over-limit digit runs The previous commit moved the :max_digits check past digits_acc_to_integer, so adversarial inputs like "9" * 1_000_000 spent ~260ms building the coefficient before being rejected. The CVE-2026-32686 mitigation specifically relied on rejecting at parse_digits_count to avoid that materialization. Track leading zeros while parsing (and skip accumulating them), then check total_size - leading_zeros against :max_digits before constructing the coefficient. Restores the parse-time bound for digit-rich inputs and keeps the list size bounded by significant digits for leading-zero inputs, while preserving the round-trip fix. --- lib/decimal.ex | 41 ++++++++++++++++++++++++++++------------- test/decimal_test.exs | 9 +++++++++ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/lib/decimal.ex b/lib/decimal.ex index 7622ecd..1494ab6 100644 --- a/lib/decimal.ex +++ b/lib/decimal.ex @@ -2594,11 +2594,19 @@ defmodule Decimal do "#{inspect(key)} must be a non-negative integer or :infinity, got: #{inspect(value)}" end - defp parse_digits_count(<>, acc, count) when digit in ?0..?9 do - parse_digits_count(rest, [digit | acc], count + 1) + defp parse_digits_count(<>, acc, count, leading_zeros) + when count == leading_zeros do + parse_digits_count(rest, acc, count + 1, leading_zeros + 1) end - defp parse_digits_count(rest, acc, count), do: {acc, count, rest} + defp parse_digits_count(<>, acc, count, leading_zeros) + when digit in ?0..?9 do + parse_digits_count(rest, [digit | acc], count + 1, leading_zeros) + end + + defp parse_digits_count(rest, acc, count, leading_zeros) do + {acc, count, leading_zeros, rest} + end defp digits_acc_to_integer([], _size), do: 0 defp digits_acc_to_integer(acc, _size), do: :erlang.list_to_integer(:lists.reverse(acc)) @@ -2646,29 +2654,36 @@ defmodule Decimal do end defp parse_unsign(bin, limits) do - {int_rev, int_size, after_int} = parse_digits_count(bin, [], 0) + {int_rev, int_size, leading_zeros, after_int} = parse_digits_count(bin, [], 0, 0) - {coef_rev, total_size, after_float} = + {coef_rev, total_size, leading_zeros, after_float} = case after_int do - <> -> parse_digits_count(after_dot, int_rev, int_size) - _ -> {int_rev, int_size, after_int} + <> -> + parse_digits_count(after_dot, int_rev, int_size, leading_zeros) + + _ -> + {int_rev, int_size, leading_zeros, after_int} end cond do total_size == 0 -> :error + exceeds_limit?(total_size - leading_zeros, limits.max_digits) -> + :error + true -> {exp, rest} = parse_exp(after_float) exp_chars = if exp == [], do: ~c"0", else: exp float_size = total_size - int_size - with {:ok, exp_int} <- bounded_exponent(exp_chars, float_size, limits.max_exponent), - coef = digits_acc_to_integer(coef_rev, total_size), - false <- exceeds_limit?(decimal_digit_count(coef), limits.max_digits) do - {%Decimal{coef: coef, exp: exp_int}, rest} - else - _ -> :error + case bounded_exponent(exp_chars, float_size, limits.max_exponent) do + {:ok, exp_int} -> + coef = digits_acc_to_integer(coef_rev, total_size) + {%Decimal{coef: coef, exp: exp_int}, rest} + + :error -> + :error end end end diff --git a/test/decimal_test.exs b/test/decimal_test.exs index 34e2f1c..d3d70be 100644 --- a/test/decimal_test.exs +++ b/test/decimal_test.exs @@ -123,6 +123,15 @@ defmodule DecimalTest do end) end + @tag timeout: @bounded_smoke_timeout + test "parse/2 rejects very long digit runs without materializing them" do + input = String.duplicate("9", 1_000_000) + + assert_runs_quickly("parse/2 bounded digit rejection", fn -> + assert Decimal.parse(input, max_digits: 34) == :error + end) + end + test "parse/2 with very long digit strings under explicit limits" do digits = String.duplicate("9", 50_000) coef = :erlang.binary_to_integer(digits)