From c6b6df71208b832fe8a648ea1bf4d11e4183372b Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Tue, 11 Aug 2026 23:20:47 +0200 Subject: [PATCH 01/10] Rename test methods --- tests/BigDecimalTest.php | 2 +- tests/BigIntegerTest.php | 2 +- tests/BigNumberTest.php | 2 +- tests/BigRationalTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php index 1587f17..66bffdb 100644 --- a/tests/BigDecimalTest.php +++ b/tests/BigDecimalTest.php @@ -55,7 +55,7 @@ public function testOf(int|string $value, string $expected): void * @param string $expected The expected decimal value. */ #[DataProvider('providerOf')] - public function testOfNullableWithValidInputBehavesLikeOf(int|string $value, string $expected): void + public function testOfNullableWithNonNullInput(int|string $value, string $expected): void { $result = BigDecimal::ofNullable($value); diff --git a/tests/BigIntegerTest.php b/tests/BigIntegerTest.php index 213edd5..5b639f2 100644 --- a/tests/BigIntegerTest.php +++ b/tests/BigIntegerTest.php @@ -58,7 +58,7 @@ public function testOf(int|string $value, string $expected): void * @param string $expected The expected string value of the result. */ #[DataProvider('providerOf')] - public function testOfNullableWithValidInputBehavesLikeOf(mixed $value, string $expected): void + public function testOfNullableWithNonNullInput(mixed $value, string $expected): void { $result = BigInteger::ofNullable($value); diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php index 375c996..b1a6873 100644 --- a/tests/BigNumberTest.php +++ b/tests/BigNumberTest.php @@ -36,7 +36,7 @@ public function testOf(BigNumber|int|string $value, string $expectedClass, strin } #[DataProvider('providerOf')] - public function testOfNullableWithValidInputBehavesLikeOf(mixed $value, string $expectedClass, string $expectedValue): void + public function testOfNullableWithNonNullInput(mixed $value, string $expectedClass, string $expectedValue): void { $result = BigNumber::ofNullable($value); diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index 5c88490..6947a03 100644 --- a/tests/BigRationalTest.php +++ b/tests/BigRationalTest.php @@ -86,7 +86,7 @@ public function testOf(string $string, string $expected): void * @param string $expected The expected rational result. */ #[DataProvider('providerOf')] - public function testOfNullableWithValidInputBehavesLikeOf(string $string, string $expected): void + public function testOfNullableWithNonNullInput(string $string, string $expected): void { $result = BigRational::ofNullable($string); From 5b5b5164f687d8a729ad770cc1e7ab90f8778096 Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 01:54:41 +0200 Subject: [PATCH 02/10] Split _of() into _of()/_parse() --- src/BigNumber.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/BigNumber.php b/src/BigNumber.php index 5a4f326..c264bc8 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -564,6 +564,17 @@ private static function _of(BigNumber|int|string $value): BigNumber return new BigInteger((string) $value); } + return self::_parse($value); + } + + /** + * @throws NumberFormatException If the format of the number is not valid. + * @throws DivisionByZeroException If the value represents a rational number with a denominator of zero. + * + * @pure + */ + private static function _parse(string $value): BigNumber + { if ($value === '') { throw NumberFormatException::emptyNumber(); } From 630b02f7e035fd9e0594b7c068a9d3871fe19e1f Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 02:04:11 +0200 Subject: [PATCH 03/10] Use possessive quantifiers in parse regexps --- src/BigNumber.php | 14 +++++++++----- tests/BigNumberTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index c264bc8..0d4c803 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -44,27 +44,31 @@ * The regular expression used to parse integer or decimal numbers. * * The end anchor must be \z, not $: the latter would also match before a trailing newline. + * The digit quantifiers must be possessive (++): backtracking on malformed input could exhaust + * pcre.backtrack_limit, surfacing as PlatformException instead of NumberFormatException. */ private const PARSE_REGEXP_NUMERICAL = '/^' . '(?[\-\+])?' . - '(?[0-9]+)?' . + '(?[0-9]++)?' . '(?\.)?' . - '(?[0-9]+)?' . - '(?:[eE](?[\-\+]?[0-9]+))?' . + '(?[0-9]++)?' . + '(?:[eE](?[\-\+]?[0-9]++))?' . '\z/'; /** * The regular expression used to parse rational numbers. * * The end anchor must be \z, not $: the latter would also match before a trailing newline. + * The digit quantifiers must be possessive (++): backtracking on malformed input could exhaust + * pcre.backtrack_limit, surfacing as PlatformException instead of NumberFormatException. */ private const PARSE_REGEXP_RATIONAL = '/^' . '(?[\-\+])?' . - '(?[0-9]+)' . + '(?[0-9]++)' . '\/' . - '(?[0-9]+)' . + '(?[0-9]++)' . '\z/'; /** diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php index b1a6873..6369435 100644 --- a/tests/BigNumberTest.php +++ b/tests/BigNumberTest.php @@ -17,6 +17,7 @@ use function explode; use function preg_match; use function sprintf; +use function str_repeat; /** * Unit tests for class BigNumber. @@ -155,6 +156,29 @@ public static function providerOfInvalidFormatThrowsException(): array ]; } + /** + * Input designed to force heavy backtracking in the parse regexps must be rejected as an invalid number. + * If backtracking is not eliminated, these inputs exhaust pcre.backtrack_limit, and the failed PCRE match + * surfaces as a PlatformException instead of the promised NumberFormatException. + */ + #[DataProvider('providerOfAdversarialInputThrowsException')] + public function testOfAdversarialInputThrowsException(string $value): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageMatches('/^Value "[^"]++" does not represent a valid number\.$/'); + + BigNumber::of($value); + } + + public static function providerOfAdversarialInputThrowsException(): array + { + return [ + [str_repeat('1', 10_000) . '!'], + ['.' . str_repeat('1', 2_000_000) . '!'], + ['1/' . str_repeat('2', 2_000_000) . '!'], + ]; + } + /** * @param list $values */ From 3748006d0c784c87cc5a9460c266572b6c6a339a Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 02:08:57 +0200 Subject: [PATCH 04/10] Make zero denominator in of() a NumberFormatException --- src/BigNumber.php | 11 +++-------- src/Exception/NumberFormatException.php | 10 ++++++++++ tests/BigRationalTest.php | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index 0d4c803..4547a16 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -4,7 +4,6 @@ namespace Brick\Math; -use Brick\Math\Exception\DivisionByZeroException; use Brick\Math\Exception\IntegerOverflowException; use Brick\Math\Exception\InvalidArgumentException; use Brick\Math\Exception\MathException; @@ -87,7 +86,6 @@ * of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown. * * @throws NumberFormatException If the format of the number is not valid. - * @throws DivisionByZeroException If the value represents a rational number with a denominator of zero. * @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding. * * @pure @@ -113,7 +111,6 @@ final public static function of(BigNumber|int|string $value): static * @see BigNumber::of() * * @throws NumberFormatException If the format of the number is not valid. - * @throws DivisionByZeroException If the value represents a rational number with a denominator of zero. * @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding. * * @pure @@ -553,8 +550,7 @@ final protected function newBigRational(BigInteger $numerator, BigInteger $denom } /** - * @throws NumberFormatException If the format of the number is not valid. - * @throws DivisionByZeroException If the value represents a rational number with a denominator of zero. + * @throws NumberFormatException If the format of the number is not valid. * * @pure */ @@ -572,8 +568,7 @@ private static function _of(BigNumber|int|string $value): BigNumber } /** - * @throws NumberFormatException If the format of the number is not valid. - * @throws DivisionByZeroException If the value represents a rational number with a denominator of zero. + * @throws NumberFormatException If the format of the number is not valid. * * @pure */ @@ -603,7 +598,7 @@ private static function _parse(string $value): BigNumber $denominator = self::cleanUp(null, $denominator); if ($denominator === '0') { - throw DivisionByZeroException::zeroDenominator(); + throw NumberFormatException::zeroDenominator(); } return new BigRational( diff --git a/src/Exception/NumberFormatException.php b/src/Exception/NumberFormatException.php index 5dcb150..4d61c47 100644 --- a/src/Exception/NumberFormatException.php +++ b/src/Exception/NumberFormatException.php @@ -98,6 +98,16 @@ public static function exponentTooLarge(): self return new self('The exponent is too large to be represented as an integer.'); } + /** + * @internal + * + * @pure + */ + public static function zeroDenominator(): self + { + return new self('The denominator of a rational number must not be zero.'); + } + /** * @pure */ diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index 6947a03..c60ddfc 100644 --- a/tests/BigRationalTest.php +++ b/tests/BigRationalTest.php @@ -130,7 +130,7 @@ public static function providerOf(): array public function testOfWithZeroDenominator(): void { - $this->expectException(DivisionByZeroException::class); + $this->expectException(NumberFormatException::class); $this->expectExceptionMessageExact('The denominator of a rational number must not be zero.'); BigRational::of('2/0'); From fa719f00f4aaf3826c1954afd4bc12aecd68f751 Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 02:10:22 +0200 Subject: [PATCH 05/10] Replace is_null() with === null --- src/BigNumber.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index 4547a16..e99858b 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -18,7 +18,6 @@ use function assert; use function filter_var; use function is_int; -use function is_null; use function ltrim; use function preg_match; use function str_contains; @@ -117,7 +116,7 @@ final public static function of(BigNumber|int|string $value): static */ final public static function ofNullable(BigNumber|int|string|null $value): ?static { - if (is_null($value)) { + if ($value === null) { return null; } From 8ddee78056f11949e6a08fe45e4039713937f12d Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 02:11:30 +0200 Subject: [PATCH 06/10] Improve docblock documentation --- src/BigNumber.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index e99858b..084fd0f 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -84,8 +84,9 @@ * When of() is called on BigInteger, BigDecimal, or BigRational, the resulting number is converted to an instance * of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown. * - * @throws NumberFormatException If the format of the number is not valid. - * @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding. + * @throws NumberFormatException If the input is a string, and the format of the number is not valid. + * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be + * converted to an instance of the subclass without rounding. * * @pure */ @@ -105,12 +106,11 @@ final public static function of(BigNumber|int|string $value): static /** * Creates a BigNumber of the given value, or returns null if the input is null. * - * Behaves like of() for non-null values. + * Behaves like {@see of()} for non-null values. * - * @see BigNumber::of() - * - * @throws NumberFormatException If the format of the number is not valid. - * @throws RoundingNecessaryException If the value cannot be converted to an instance of the subclass without rounding. + * @throws NumberFormatException If the input is a string, and the format of the number is not valid. + * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be + * converted to an instance of the subclass without rounding. * * @pure */ @@ -662,7 +662,7 @@ private static function _parse(string $value): BigNumber $scale = strlen($fractional) - $exponent; - // @phpstan-ignore function.alreadyNarrowedType + // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) if (! is_int($scale)) { throw NumberFormatException::exponentTooLarge(); } From 825b413b90aceb8138c3c1ef6e8ddcc29a3c7cf8 Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 13:24:45 +0200 Subject: [PATCH 07/10] Sanitize string in NumberFormatException message --- src/Exception/NumberFormatException.php | 94 +++++++++++++++++++++---- tests/BigDecimalTest.php | 14 ++-- tests/BigIntegerTest.php | 39 ++++++---- tests/BigNumberTest.php | 20 +++--- tests/BigRationalTest.php | 13 ++-- 5 files changed, 136 insertions(+), 44 deletions(-) diff --git a/src/Exception/NumberFormatException.php b/src/Exception/NumberFormatException.php index 4d61c47..fd81384 100644 --- a/src/Exception/NumberFormatException.php +++ b/src/Exception/NumberFormatException.php @@ -6,16 +6,29 @@ use RuntimeException; -use function dechex; use function ord; +use function preg_match; use function sprintf; -use function strtoupper; +use function strlen; +use function strtr; +use function substr; /** * Exception thrown when attempting to create a number from a string with an invalid format. */ final class NumberFormatException extends RuntimeException implements MathException { + /** + * Invisible characters commonly found in copy-pasted numbers, escaped even in valid UTF-8: + * kept as-is, they would misleadingly look valid in the message. + */ + private const INVISIBLE_CHAR_ESCAPES = [ + "\u{00A0}" => '\u{00A0}', // no-break space + "\u{202F}" => '\u{202F}', // narrow no-break space + "\u{200B}" => '\u{200B}', // zero-width space + "\u{FEFF}" => '\u{FEFF}', // zero-width no-break space (BOM) + ]; + /** * @internal * @@ -34,8 +47,8 @@ public function __construct(string $message) public static function invalidFormat(string $value): self { return new self(sprintf( - 'Value "%s" does not represent a valid number.', - $value, + 'Value %s does not represent a valid number.', + self::valueToString($value), )); } @@ -109,22 +122,77 @@ public static function zeroDenominator(): self } /** + * Renders a value in a form safe to embed in an exception message: the value is truncated, printable + * ASCII and valid UTF-8 text are kept, and everything else is escaped: `\t`, `\n` and `\r` for the + * common whitespace controls, `\\` and `\"` for the backslash and the double quote, `\xHH` for other + * bytes, and `\u{XXXX}` for a few invisible characters commonly found in copy-pasted numbers, which + * would misleadingly look valid if kept. When the value is not valid UTF-8, every non-ASCII byte is + * escaped. + * * @pure */ - private static function charToString(string $char): string + private static function valueToString(string $value): string { - $ord = ord($char); - - if ($ord < 32 || $ord > 126) { - $char = strtoupper(dechex($ord)); + if (strlen($value) > 40) { + $value = substr($value, 0, 40); - if ($ord < 16) { - $char = '0' . $char; + // If the cut falls inside a multibyte sequence, drop that sequence's leading bytes as well. + for ($i = 0; $i < 3 && ! self::isUtf8($value); $i++) { + $value = substr($value, 0, -1); } - return '0x' . $char; + $value .= '...'; + } + + $isUtf8 = self::isUtf8($value); + + $escaped = ''; + $length = strlen($value); + + for ($i = 0; $i < $length; $i++) { + $char = $value[$i]; + + $escaped .= $isUtf8 && ord($char) >= 0x80 ? $char : self::escapeChar($char); } - return '"' . $char . '"'; + return '"' . strtr($escaped, self::INVISIBLE_CHAR_ESCAPES) . '"'; + } + + /** + * @param string $char The failing character. + * + * @pure + */ + private static function charToString(string $char): string + { + return '"' . self::escapeChar($char) . '"'; + } + + /** + * @pure + */ + private static function escapeChar(string $char): string + { + $ord = ord($char); + + return match (true) { + $char === "\t" => '\t', + $char === "\n" => '\n', + $char === "\r" => '\r', + $char === '\\' => '\\\\', + $char === '"' => '\"', + $ord < 32 || $ord > 126 => sprintf('\x%02X', $ord), + default => $char, + }; + } + + /** + * An empty pattern with the /u modifier matches if, and only if, the subject is valid UTF-8. + * + * @pure + */ + private static function isUtf8(string $value): bool + { + return preg_match('//u', $value) === 1; } } diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php index 66bffdb..7164e4a 100644 --- a/tests/BigDecimalTest.php +++ b/tests/BigDecimalTest.php @@ -226,11 +226,15 @@ public function testOfEmptyStringThrowsException(): void BigDecimal::of(''); } + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, when it differs from $value. + */ #[DataProvider('providerOfInvalidFormatThrowsException')] - public function testOfInvalidFormatThrowsException(string $value): void + public function testOfInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void { $this->expectException(NumberFormatException::class); - $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $value)); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); BigDecimal::of($value); } @@ -241,9 +245,9 @@ public static function providerOfInvalidFormatThrowsException(): array ['a'], [' 1'], ['1 '], - ["\n1.2"], - ["1.2\n"], - ["1e2\n"], + ["\n1.2", '\n1.2'], + ["1.2\n", '1.2\n'], + ["1e2\n", '1e2\n'], ['..1'], ['1..'], ['.1.'], diff --git a/tests/BigIntegerTest.php b/tests/BigIntegerTest.php index 5b639f2..c5fc862 100644 --- a/tests/BigIntegerTest.php +++ b/tests/BigIntegerTest.php @@ -153,11 +153,15 @@ public function testOfEmptyStringThrowsException(): void BigInteger::of(''); } + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, when it differs from $value. + */ #[DataProvider('providerOfInvalidFormatThrowsException')] - public function testOfInvalidFormatThrowsException(string $value): void + public function testOfInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void { $this->expectException(NumberFormatException::class); - $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $value)); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); BigInteger::of($value); } @@ -168,8 +172,8 @@ public static function providerOfInvalidFormatThrowsException(): array ['a'], [' 1'], ['1 '], - ["\n123"], - ["123\n"], + ["\n123", '\n123'], + ["123\n", '123\n'], ['+'], ['-'], ['+a'], @@ -373,8 +377,11 @@ public static function providerFromBaseWithInvalidValue(): array ['12g34G56', 16, 'Character "g" is not valid in base 16.'], ['-12k34', 20, 'Character "k" is not valid in base 20.'], ['+12K34', 20, 'Character "K" is not valid in base 20.'], - ["+\0", 10, 'Character 0x00 is not valid in base 10.'], - ["+\x01", 10, 'Character 0x01 is not valid in base 10.'], + ["+\0", 10, 'Character "\x00" is not valid in base 10.'], + ["+\x01", 10, 'Character "\x01" is not valid in base 10.'], + // fromBase() is byte-oriented: a multibyte character is reported as its first byte + ["12\u{0663}4", 10, 'Character "\xD9" is not valid in base 10.'], + ["1\u{00A0}000", 10, 'Character "\xC2" is not valid in base 10.'], ]; } @@ -4975,12 +4982,20 @@ public static function providerFromArbitraryBaseWithInvalidNumber(): array ['1', 'XY', 'Character "1" is not valid in the given alphabet.'], [' ', 'XY', 'Character " " is not valid in the given alphabet.'], - ["\x00", '01', 'Character 0x00 is not valid in the given alphabet.'], - ["\x0A", '01', 'Character 0x0A is not valid in the given alphabet.'], - ["\x1F", '01', 'Character 0x1F is not valid in the given alphabet.'], - ["\x7F", '01', 'Character 0x7F is not valid in the given alphabet.'], - ["\x80", '01', 'Character 0x80 is not valid in the given alphabet.'], - ["\xFF", '01', 'Character 0xFF is not valid in the given alphabet.'], + ["\x00", '01', 'Character "\x00" is not valid in the given alphabet.'], + ["\x09", '01', 'Character "\t" is not valid in the given alphabet.'], + ["\x0A", '01', 'Character "\n" is not valid in the given alphabet.'], + ["\x0D", '01', 'Character "\r" is not valid in the given alphabet.'], + ["\x1F", '01', 'Character "\x1F" is not valid in the given alphabet.'], + ["\x7F", '01', 'Character "\x7F" is not valid in the given alphabet.'], + ["\x80", '01', 'Character "\x80" is not valid in the given alphabet.'], + ["\xFF", '01', 'Character "\xFF" is not valid in the given alphabet.'], + ['"', '01', 'Character "\"" is not valid in the given alphabet.'], + ['\\', '01', 'Character "\\\\" is not valid in the given alphabet.'], + // fromArbitraryBase() is byte-oriented: a multibyte character is reported as its first byte + ["0\u{0663}1", '01', 'Character "\xD9" is not valid in the given alphabet.'], + ["0\u{00A0}1", '01', 'Character "\xC2" is not valid in the given alphabet.'], + ["0\xD94", '01', 'Character "\xD9" is not valid in the given alphabet.'], ]; } diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php index 6369435..bb273de 100644 --- a/tests/BigNumberTest.php +++ b/tests/BigNumberTest.php @@ -105,11 +105,15 @@ public function testOfEmptyStringThrowsException(): void BigNumber::of(''); } + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, when it differs from $value. + */ #[DataProvider('providerOfInvalidFormatThrowsException')] - public function testOfInvalidFormatThrowsException(string $value): void + public function testOfInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void { $this->expectException(NumberFormatException::class); - $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $value)); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); BigNumber::of($value); } @@ -120,12 +124,12 @@ public static function providerOfInvalidFormatThrowsException(): array ['a'], [' 1'], ['1 '], - ["\n123"], - ["123\n"], - ["1.2\n"], - ["1e2\n"], - ["2/3\n"], - ["1/0\n"], + ["\n123", '\n123'], + ["123\n", '123\n'], + ["1.2\n", '1.2\n'], + ["1e2\n", '1e2\n'], + ["2/3\n", '2/3\n'], + ["1/0\n", '1/0\n'], ['+'], ['-'], ['+a'], diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index c60ddfc..0f11cfb 100644 --- a/tests/BigRationalTest.php +++ b/tests/BigRationalTest.php @@ -145,13 +145,14 @@ public function testOfEmptyStringThrowsException(): void } /** - * @param string $string An invalid string representation. + * @param string $string An invalid string representation. + * @param string|null $expectedValueInMessage The value as rendered in the message, when it differs from $string. */ #[DataProvider('providerOfInvalidFormatThrowsException')] - public function testOfInvalidFormatThrowsException(string $string): void + public function testOfInvalidFormatThrowsException(string $string, ?string $expectedValueInMessage = null): void { $this->expectException(NumberFormatException::class); - $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $string)); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $string)); BigRational::of($string); } @@ -165,9 +166,9 @@ public static function providerOfInvalidFormatThrowsException(): array ['1e2/3'], [' 1/2'], ['1/2 '], - ["\n2/3"], - ["2/3\n"], - ["1/0\n"], + ["\n2/3", '\n2/3'], + ["2/3\n", '2/3\n'], + ["1/0\n", '1/0\n'], ['+'], ['-'], ['/'], From 6f1b4d9885cbc384fd3be328c40ab33ba1e715cc Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Thu, 20 Aug 2026 22:25:44 +0200 Subject: [PATCH 08/10] Add BigNumber::parse() / parseNullable() --- README.md | 19 + src/BigNumber.php | 157 +++++++- src/Exception/InvalidArgumentException.php | 10 + src/Exception/NumberFormatException.php | 28 ++ src/NumberSyntax.php | 80 ++++ tests/BigDecimalTest.php | 118 ++++++ tests/BigIntegerTest.php | 96 +++++ tests/BigNumberTest.php | 404 +++++++++++++++++++++ tests/BigRationalTest.php | 89 +++++ tests/NumberSyntaxTest.php | 26 ++ 10 files changed, 1023 insertions(+), 4 deletions(-) create mode 100644 src/NumberSyntax.php create mode 100644 tests/NumberSyntaxTest.php diff --git a/README.md b/README.md index fc021fa..3658e47 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,21 @@ BigRational::of('1.15'); // 23/20 (reduced to lowest terms) > BigDecimal::fromFloatShortest(0.1); // 0.1 > ``` +> [!CAUTION] +> The `of()` factory method is for trusted input: a string as short as `1e1000000000` can expand to gigabytes of +> memory and exceed PHP's memory limit. +> +> For untrusted user input, use `parse()` instead: +> +> ```php +> BigDecimal::parse('1000000000000000000000', allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 100); // OK +> BigDecimal::parse('1e1000000000', allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 100); // NumberFormatException +> ``` +> +> `parse()` counts every digit as written, so no amount of leading zeros can pad an input into being accepted: +> an accepted value is never longer than `maxDigits` plus a few punctuation characters, and the input length does +> not need to be validated separately. + #### Immutability & chaining The `BigInteger`, `BigDecimal` and `BigRational` classes are immutable: their value never changes, @@ -148,6 +163,10 @@ echo BigInteger::of(2)->multipliedBy(BigDecimal::of('2.5')); // RoundingNecessar echo BigDecimal::of(2.5)->multipliedBy(BigInteger::of(2)); // 5.0 ``` +> [!CAUTION] +> These parameters are converted with `of()`, so the same caution applies: never pass an untrusted string +> directly to an arithmetic or comparison method — `parse()` it first, and pass the resulting number. + #### Division & rounding ##### BigInteger diff --git a/src/BigNumber.php b/src/BigNumber.php index 084fd0f..a4f9d9f 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -17,8 +17,10 @@ use function assert; use function filter_var; +use function in_array; use function is_int; use function ltrim; +use function max; use function preg_match; use function str_contains; use function str_repeat; @@ -26,6 +28,7 @@ use function substr; use const FILTER_VALIDATE_INT; +use const PHP_INT_MAX; use const PREG_UNMATCHED_AS_NULL; /** @@ -82,7 +85,9 @@ * - strings containing only digits with an optional leading `+` or `-` sign are returned as BigInteger * * When of() is called on BigInteger, BigDecimal, or BigRational, the resulting number is converted to an instance - * of the subclass when possible; otherwise a RoundingNecessaryException exception is thrown. + * of the subclass when possible; otherwise a RoundingNecessaryException is thrown. + * + * When parsing untrusted input, use {@see parse()} instead. * * @throws NumberFormatException If the input is a string, and the format of the number is not valid. * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be @@ -108,6 +113,8 @@ final public static function of(BigNumber|int|string $value): static * * Behaves like {@see of()} for non-null values. * + * When parsing untrusted input, use {@see parseNullable()} instead. + * * @throws NumberFormatException If the input is a string, and the format of the number is not valid. * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be * converted to an instance of the subclass without rounding. @@ -123,6 +130,98 @@ final public static function ofNullable(BigNumber|int|string|null $value): ?stat return static::of($value); } + /** + * Creates a BigNumber of the given string, limiting the allowed syntax and the number of digits. + * + * This method is designed to safely parse untrusted input: huge strings, and exponential notation that allows a + * short string such as `1e1000000000` to expand to gigabytes of memory. + * + * The $allowedSyntax parameter restricts the accepted notations: plain integers such as `123` are always accepted, + * then each NumberSyntax case allows one additional feature: DecimalPoint, Exponent, Fraction. A value is accepted + * only if every feature it uses is allowed. The NumberSyntax enum also provides constants for the most common + * combinations, from NumberSyntax::INTEGER to NumberSyntax::ALL. + * + * The $maxDigits parameter limits the number of digits, counted in each of these two forms: + * + * - as written, where every digit of the input counts, including leading zeros and exponent digits: `005` counts + * 3 digits, `1e-3` counts 2, and `010/012` counts 6; + * - in its final form, with the number written out plainly, before simplification for rationals: `005` counts 1 + * digit (`5`), `1e-3` counts 4 (`0.001`), and `010/012` counts 4 (`10/12`). + * + * When parse() is called on BigNumber, the concrete return type is determined by the format of the string, + * following the same rules as {@see of()}. When called on a subclass, the value is converted to an instance of + * that subclass when possible. The $maxDigits limit applies to the number as parsed, before this conversion: the + * converted number may count slightly more digits, as in `BigDecimal::parse('1/8', ...)` where `1/8` counts 2 + * digits, but the resulting `0.125` counts 3. + * + * @param string $value The untrusted value to parse. + * @param list $allowedSyntax The allowed syntax features; plain integers are always accepted. + * @param positive-int $maxDigits The maximum number of digits, as written and in the resulting number. + * + * @throws NumberFormatException If the format of $value is invalid, if it uses a syntax that is not allowed + * by $allowedSyntax, or if it has more than $maxDigits digits. + * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be + * converted to an instance of the subclass without rounding. + * @throws InvalidArgumentException If $maxDigits is less than 1. + * + * @pure + * + * @phpstan-ignore throws.unusedType (the $maxDigits check below is dead code for static analysis, but must exist at runtime) + */ + final public static function parse( + string $value, + array $allowedSyntax, + int $maxDigits, + ): static { + if ($maxDigits < 1) { // @phpstan-ignore smaller.alwaysFalse + throw InvalidArgumentException::nonPositiveMaxDigits(); + } + + $value = self::_parse($value, $allowedSyntax, $maxDigits); + + if (static::class === BigNumber::class) { + assert($value instanceof static); + + return $value; + } + + return static::from($value); + } + + /** + * Creates a BigNumber of the given string, limiting the allowed syntax and the number of digits, or returns null + * if the input is null. + * + * Behaves like {@see parse()} for non-null values. + * + * @param string|null $value The untrusted value to parse, or null. + * @param list $allowedSyntax The allowed syntax features; plain integers are always accepted. + * @param positive-int $maxDigits The maximum number of digits, as written and in the resulting number. + * + * @throws NumberFormatException If the format of $value is invalid, if it uses a syntax that is not allowed + * by $allowedSyntax, or if it has more than $maxDigits digits. + * @throws RoundingNecessaryException If the method is called on a subclass of BigNumber, and the value cannot be + * converted to an instance of the subclass without rounding. + * @throws InvalidArgumentException If $maxDigits is less than 1. + * + * @pure + */ + final public static function parseNullable( + ?string $value, + array $allowedSyntax, + int $maxDigits, + ): ?static { + if ($value === null) { + if ($maxDigits < 1) { // @phpstan-ignore smaller.alwaysFalse + throw InvalidArgumentException::nonPositiveMaxDigits(); + } + + return null; + } + + return static::parse($value, $allowedSyntax, $maxDigits); + } + /** * Returns the minimum of the given values. * @@ -563,15 +662,19 @@ private static function _of(BigNumber|int|string $value): BigNumber return new BigInteger((string) $value); } - return self::_parse($value); + return self::_parse($value, NumberSyntax::ALL, PHP_INT_MAX); } /** - * @throws NumberFormatException If the format of the number is not valid. + * @param list $allowedSyntax The allowed syntax features; plain integers are always accepted. + * @param positive-int $maxDigits The maximum number of digits, as written and in the resulting number. + * + * @throws NumberFormatException If the format of $value is invalid, if it uses a syntax that is not allowed + * by $allowedSyntax, or if it has more than $maxDigits digits. * * @pure */ - private static function _parse(string $value): BigNumber + private static function _parse(string $value, array $allowedSyntax, int $maxDigits): BigNumber { if ($value === '') { throw NumberFormatException::emptyNumber(); @@ -589,10 +692,19 @@ private static function _parse(string $value): BigNumber throw NumberFormatException::invalidFormat($value); } + if (! in_array(NumberSyntax::Fraction, $allowedSyntax, true)) { + throw NumberFormatException::syntaxNotAllowed(NumberSyntax::Fraction); + } + $sign = $matches['sign']; $numerator = $matches['numerator']; $denominator = $matches['denominator']; + // Digit count is recorded before trimming zeros and before simplification: + // the final count will always be less or equal. + $numeratorDigits = strlen($numerator); + $denominatorDigits = strlen($denominator); + $numerator = self::cleanUp($sign, $numerator); $denominator = self::cleanUp(null, $denominator); @@ -600,6 +712,10 @@ private static function _parse(string $value): BigNumber throw NumberFormatException::zeroDenominator(); } + if ($numeratorDigits + $denominatorDigits > $maxDigits) { + throw NumberFormatException::tooManyDigits($maxDigits); + } + return new BigRational( new BigInteger($numerator), new BigInteger($denominator), @@ -629,11 +745,25 @@ private static function _parse(string $value): BigNumber throw NumberFormatException::invalidFormat($value); } + $writtenDigits = strlen($integral ?? '') + strlen($fractional ?? ''); + + if ($exponent !== null) { + $writtenDigits += strlen($exponent) - (int) ($exponent[0] === '-' || $exponent[0] === '+'); + } + if ($integral === null) { $integral = '0'; } if ($point !== null || $exponent !== null) { + if ($point !== null && ! in_array(NumberSyntax::DecimalPoint, $allowedSyntax, true)) { + throw NumberFormatException::syntaxNotAllowed(NumberSyntax::DecimalPoint); + } + + if ($exponent !== null && ! in_array(NumberSyntax::Exponent, $allowedSyntax, true)) { + throw NumberFormatException::syntaxNotAllowed(NumberSyntax::Exponent); + } + $fractional ??= ''; if ($exponent !== null) { @@ -667,6 +797,21 @@ private static function _parse(string $value): BigNumber throw NumberFormatException::exponentTooLarge(); } + $digits = strlen($unscaledValue) - (int) ($unscaledValue[0] === '-'); + + if ($scale < 0 && $unscaledValue !== '0') { + // The unscaled value is padded with -$scale zeros below. + $count = $digits - $scale; + } else { + // The fractional digits, plus at least a zero integer part. + $count = max($digits, $scale + 1); + } + + // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) + if (! is_int($count) || $count > $maxDigits || $writtenDigits > $maxDigits) { + throw NumberFormatException::tooManyDigits($maxDigits); + } + if ($scale < 0) { if ($unscaledValue !== '0') { $unscaledValue .= str_repeat('0', Safe::neg($scale)); @@ -677,6 +822,10 @@ private static function _parse(string $value): BigNumber return new BigDecimal($unscaledValue, $scale); } + if ($writtenDigits > $maxDigits) { + throw NumberFormatException::tooManyDigits($maxDigits); + } + $integral = self::cleanUp($sign, $integral); return new BigInteger($integral); diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index f30ef31..a7ee9bd 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -130,4 +130,14 @@ public static function nonPositiveNthRootDegree(): self { return new self('The degree of an nth root must be a positive integer.'); } + + /** + * @internal + * + * @pure + */ + public static function nonPositiveMaxDigits(): self + { + return new self('The maximum number of digits must be a positive integer.'); + } } diff --git a/src/Exception/NumberFormatException.php b/src/Exception/NumberFormatException.php index fd81384..8d22936 100644 --- a/src/Exception/NumberFormatException.php +++ b/src/Exception/NumberFormatException.php @@ -4,6 +4,7 @@ namespace Brick\Math\Exception; +use Brick\Math\NumberSyntax; use RuntimeException; use function ord; @@ -111,6 +112,33 @@ public static function exponentTooLarge(): self return new self('The exponent is too large to be represented as an integer.'); } + /** + * @internal + * + * @pure + */ + public static function tooManyDigits(int $maxDigits): self + { + return new self(sprintf( + 'The number exceeds the maximum number of %d digits.', + $maxDigits, + )); + } + + /** + * @internal + * + * @pure + */ + public static function syntaxNotAllowed(NumberSyntax $syntax): self + { + return new self(sprintf('The %s syntax is not allowed.', match ($syntax) { + NumberSyntax::DecimalPoint => 'decimal point', + NumberSyntax::Exponent => 'exponent', + NumberSyntax::Fraction => 'fraction', + })); + } + /** * @internal * diff --git a/src/NumberSyntax.php b/src/NumberSyntax.php new file mode 100644 index 0000000..edc6c53 --- /dev/null +++ b/src/NumberSyntax.php @@ -0,0 +1,80 @@ +expectException(NumberFormatException::class); + $this->expectExceptionMessage("The number exceeds the maximum number of $maxDigits digits."); + + BigDecimal::parse($value, allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: $maxDigits); + } + + public static function providerParse(): array + { + return [ + ['123.45', 5, '123.45'], + ['1000000000000000000000.999000', 28, '1000000000000000000000.999000'], + ['1.5e-3', 5, '0.0015'], + ['0.00000000001e11', 14, '1'], // 1 digit in its final form, but 14 as written: leading zeros count + ]; + } + + public static function providerParseExceeded(): Generator + { + // Every accepted row of the matrix above must be rejected at one digit less. + foreach (self::providerParse() as [$value, $digitCount]) { + if ($digitCount > 1) { + yield [$value, $digitCount - 1]; + } + } + + // Rejection-only case: this number cannot appear in the matrix above, as it would allocate ~1 GB. + yield ['1e1000000000', 100]; + } + + public function testParseWithoutFractionSyntax(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The fraction syntax is not allowed.'); + + BigDecimal::parse('1/4', [NumberSyntax::DecimalPoint], 10); + } + + /** + * The digit limit applies to the number as parsed, before its conversion to BigDecimal. + */ + public function testParseWithFractionSyntaxConvertsExactValue(): void + { + // 2 digits as parsed, although the converted result has 3 + self::assertBigDecimalEquals('0.25', BigDecimal::parse('1/4', NumberSyntax::ALL, 2)); + } + + /** + * `NumberSyntax::SCIENTIFIC` accepts integers, decimal numbers, and exponents, but not fractions. + */ + public function testParseWithScientificSyntaxRejectsFractions(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The fraction syntax is not allowed.'); + + BigDecimal::parse('1/4', NumberSyntax::SCIENTIFIC, 10); + } + + /** + * An exponent too large to process must be reported as such: with a limit of 1 digit, the numbers below + * also exceed the digit limit, but the exponent check takes precedence. + */ + #[DataProvider('providerParseExponentTooLargeThrowsException')] + public function testParseExponentTooLargeThrowsException(string $value): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The exponent is too large to be represented as an integer.'); + + BigDecimal::parse($value, allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 1); + } + + public static function providerParseExponentTooLargeThrowsException(): array + { + return [ + ['1e1000000000000000000000000000000'], + ['1e-1000000000000000000000000000000'], + ['1.5e-' . PHP_INT_MAX], // the exponent fits in a native integer, but the scale overflows + ]; + } + + /** + * A number whose digit count overflows a native integer cannot fit within any digit limit, not even PHP_INT_MAX. + */ + #[DataProvider('providerParseDigitCountOverflow')] + public function testParseDigitCountOverflow(string $value): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The number exceeds the maximum number of ' . PHP_INT_MAX . ' digits.'); + + BigDecimal::parse($value, allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: PHP_INT_MAX); + } + + public static function providerParseDigitCountOverflow(): array + { + return [ + ['1e' . PHP_INT_MAX], // a 1 followed by PHP_INT_MAX zeros + ['1e-' . PHP_INT_MAX], // a zero integer part followed by PHP_INT_MAX fractional digits + ]; + } + /** * @param int|string $unscaledValue The unscaled value of the BigDecimal to create. * @param int $scale The scale of the BigDecimal to create. diff --git a/tests/BigIntegerTest.php b/tests/BigIntegerTest.php index c5fc862..8cfaa99 100644 --- a/tests/BigIntegerTest.php +++ b/tests/BigIntegerTest.php @@ -17,6 +17,7 @@ use Brick\Math\Exception\RoundingNecessaryException; use Brick\Math\Internal\Calculator; use Brick\Math\Internal\CalculatorRegistry; +use Brick\Math\NumberSyntax; use Brick\Math\RoundingMode; use Generator; use LogicException; @@ -203,6 +204,101 @@ public static function providerOfNonConvertibleValueThrowsException(): array ]; } + /** + * The digit limit applies to the number as parsed, before its conversion to BigInteger. + * + * @param string $value The value to parse. + * @param int $digitCount The exact number of digits in $value; parsing must succeed with this limit. + * @param string $expected The expected string value of the result. + */ + #[DataProvider('providerParse')] + public function testParse(string $value, int $digitCount, string $expected): void + { + self::assertBigIntegerEquals($expected, BigInteger::parse($value, allowedSyntax: NumberSyntax::INTEGER, maxDigits: $digitCount)); + } + + public static function providerParse(): array + { + return [ + ['123', 3, '123'], + ['00123', 5, '123'], // leading zeros count as written digits + ]; + } + + /** + * @param string $value The value to parse. + * @param int $maxDigits The tightest failing limit: one less than the exact digit count of $value. + */ + #[DataProvider('providerParseExceeded')] + public function testParseExceeded(string $value, int $maxDigits): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessage("The number exceeds the maximum number of $maxDigits digits."); + + BigInteger::parse($value, allowedSyntax: NumberSyntax::INTEGER, maxDigits: $maxDigits); + } + + public static function providerParseExceeded(): Generator + { + // Every accepted row of the matrix above must be rejected at one digit less. + foreach (self::providerParse() as [$value, $digitCount]) { + if ($digitCount > 1) { + yield [$value, $digitCount - 1]; + } + } + } + + public function testParseNonConvertibleValueThrowsException(): void + { + $this->expectException(RoundingNecessaryException::class); + $this->expectExceptionMessageExact('This rational number cannot be represented as an integer without rounding.'); + + BigInteger::parse('1/3', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2); + } + + /** + * The digit limit applies to the number as parsed, before its conversion to BigInteger. + */ + public function testParseWithFractionSyntaxConvertsExactValue(): void + { + // 2 digits as parsed, although the converted result has 1 + self::assertBigIntegerEquals('2', BigInteger::parse('4/2', NumberSyntax::RATIONAL, 2)); + } + + /** + * `NumberSyntax::INTEGER` accepts plain integers only. + */ + #[DataProvider('providerParseWithIntegerSyntaxRejectsOtherNotations')] + public function testParseWithIntegerSyntaxRejectsOtherNotations(string $value, string $expectedMessage): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact($expectedMessage); + + BigInteger::parse($value, allowedSyntax: NumberSyntax::INTEGER, maxDigits: 10); + } + + public static function providerParseWithIntegerSyntaxRejectsOtherNotations(): array + { + return [ + ['1.0', 'The decimal point syntax is not allowed.'], + ['1e2', 'The exponent syntax is not allowed.'], + ['4/2', 'The fraction syntax is not allowed.'], + ]; + } + + public function testParseWithDecimalPointSyntaxConvertsExactValue(): void + { + self::assertBigIntegerEquals('1', BigInteger::parse('1.0', [NumberSyntax::DecimalPoint], 2)); + } + + public function testParseWithoutDecimalPointSyntax(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The decimal point syntax is not allowed.'); + + BigInteger::parse('1.0', [], 10); + } + /** * @param string $number The number to create. * @param int $base The base of the number. diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php index bb273de..3ecb74a 100644 --- a/tests/BigNumberTest.php +++ b/tests/BigNumberTest.php @@ -8,16 +8,24 @@ use Brick\Math\BigInteger; use Brick\Math\BigNumber; use Brick\Math\BigRational; +use Brick\Math\Exception\InvalidArgumentException; use Brick\Math\Exception\NumberFormatException; use Brick\Math\Exception\RoundingNecessaryException; +use Brick\Math\NumberSyntax; use Generator; use PHPUnit\Framework\Attributes\DataProvider; +use function array_map; use function count; use function explode; +use function implode; +use function in_array; +use function max; use function preg_match; +use function preg_replace; use function sprintf; use function str_repeat; +use function strlen; /** * Unit tests for class BigNumber. @@ -183,6 +191,353 @@ public static function providerOfAdversarialInputThrowsException(): array ]; } + /** + * @param int $digitCount The exact number of digits in $value; parsing must succeed with this limit. + */ + #[DataProvider('providerParse')] + public function testParse(string $value, int $digitCount): void + { + $expected = BigNumber::of($value); + + $actual = BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $digitCount); + + self::assertSame($expected::class, $actual::class); + self::assertSame($expected->toString(), $actual->toString()); + } + + /** + * @param int $maxDigits The tightest failing limit: one less than the exact digit count of $value. + */ + #[DataProvider('providerParseExceeded')] + public function testParseExceeded(string $value, int $maxDigits): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessage("The number exceeds the maximum number of $maxDigits digits."); + + BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + #[DataProvider('providerParse')] + public function testParseNullableWithNonNullInput(string $value, int $digitCount): void + { + $expected = BigNumber::of($value); + + $actual = BigNumber::parseNullable($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $digitCount); + + self::assertNotNull($actual); + self::assertSame($expected::class, $actual::class); + self::assertSame($expected->toString(), $actual->toString()); + } + + #[DataProvider('providerParseExceeded')] + public function testParseNullableWithNonNullInputExceeded(string $value, int $maxDigits): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessage("The number exceeds the maximum number of $maxDigits digits."); + + BigNumber::parseNullable($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + public function testParseNullableWithNullInput(): void + { + self::assertNull(BigNumber::parseNullable(null, NumberSyntax::ALL, 1)); + self::assertNull(BigNumber::parseNullable(null, [], 1)); + } + + public static function providerParse(): Generator + { + // The digit count is the exact number of digits in the value's final form. + // Variations (sign, leading zeros) will be generated for each input; digits are also counted as written, + // so the effective count of each variation is the greater of the two, computed below. + $values = [ + ['0', 1], + ['1', 1], + ['23', 2], + ['1000', 4], + [str_repeat('0', 1000) . '1', 1], // 1 digit in its final form, but 1001+ as written: leading zeros count + ['.0', 2], + ['.000', 4], + ['0e100', 1], + ['0e5', 1], + ['0e-2', 3], + ['.001', 4], + ['.0001', 5], + ['.0010', 5], + // Degenerate dot forms: the integral and fractional parts are each optional. + ['5.', 1], + ['5.e3', 4], + ['5.e-3', 4], + ['.5e3', 3], + ['.5e-3', 5], + ['123.45', 5], + ['1e3', 4], + ['1.000e3', 4], + ['1.000e4', 5], + ['1.2e-2', 4], + ['1.2e-1', 3], + ['1.2e0', 2], + ['1.2e1', 2], + ['1.2e2', 3], + ['1.2e3', 4], + ['1e-9', 10], + ['1e100', 101], + // A small final form must not hide an unbounded written length. + ['0.00000000001e11', 1], // 1 digit in its final form (1), but 14 as written + ['1e' . str_repeat('0', 20) . '1', 2], // 2 digits in its final form (10), but 22 as written + ['1/3', 2], + ['22/7', 3], + ['2/4', 2], + ['7/3', 2], + ['0/5', 2], + ['1000000/1000000', 14], // counted before simplification + ]; + + foreach ($values as [$raw, $finalFormDigitCount]) { + foreach (self::generateVariations($raw) as $value) { + $writtenDigitCount = strlen((string) preg_replace('/[^0-9]/', '', $value)); + + yield [$value, max($finalFormDigitCount, $writtenDigitCount)]; + } + } + } + + public static function providerParseExceeded(): Generator + { + // Every accepted row of the main matrix must be rejected at one digit less. + foreach (self::providerParse() as [$value, $digitCount]) { + if ($digitCount > 1) { + yield [$value, $digitCount - 1]; + } + } + + // Rejection-only cases: these numbers cannot appear in providerParse, as they would allocate ~1 GB. + yield ['1e1000000000', 1_000_000_000]; // 1_000_000_001 digits + yield ['1e+1000000000', 1_000_000_000]; // 1_000_000_001 digits + yield ['-1e1000000000', 1_000_000_000]; // 1_000_000_001 digits + yield ['1e-1000000000', 1_000_000_000]; // 1_000_000_001 digits + yield ['-0.5e1000000000', 999_999_999]; // 1_000_000_000 digits + yield ['123.456e-1000000000', 1_000_000_003]; // 1_000_000_004 digits + yield ['5.e1000000000', 1_000_000_000]; // 1_000_000_001 digits, via a trailing dot + yield ['.5e-1000000000', 1_000_000_001]; // 1_000_000_002 digits, via a leading dot + } + + /** + * @param list $syntax + */ + #[DataProvider('providerParseSyntaxAllowed')] + public function testParseSyntaxAllowed(string $value, array $syntax, string $expectedValue): void + { + $number = BigNumber::parse($value, $syntax, 10); + + self::assertSame($expectedValue, $number->toString()); + } + + /** + * @param list $syntax + */ + #[DataProvider('providerParseSyntaxNotAllowed')] + public function testParseSyntaxNotAllowed(string $value, array $syntax): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageMatches('/^The (decimal point|exponent|fraction) syntax is not allowed\.$/'); + + BigNumber::parse($value, $syntax, 10); + } + + public static function providerParseSyntaxAllowed(): Generator + { + foreach (self::syntaxMatrix() as $key => [$value, $syntax, $expectedValue]) { + if ($expectedValue !== null) { + yield $key => [$value, $syntax, $expectedValue]; + } + } + } + + public static function providerParseSyntaxNotAllowed(): Generator + { + foreach (self::syntaxMatrix() as $key => [$value, $syntax, $expectedValue]) { + if ($expectedValue === null) { + yield $key => [$value, $syntax]; + } + } + } + + /** + * The syntax check runs before the exponent and digit-count checks. + */ + #[DataProvider('providerParseSyntaxIsCheckedFirst')] + public function testParseSyntaxIsCheckedFirst(string $value, string $expectedMessage): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact($expectedMessage); + + BigNumber::parse($value, [], 1); + } + + public static function providerParseSyntaxIsCheckedFirst(): array + { + return [ + ['1e99999999999999999999', 'The exponent syntax is not allowed.'], // would otherwise be exponentTooLarge + ['12.5', 'The decimal point syntax is not allowed.'], // would otherwise be tooManyDigits + ]; + } + + /** + * The format check runs before the syntax check: a value that is not a number in any syntax is reported as + * invalid, not as using a disallowed syntax. + */ + #[DataProvider('providerParseFormatIsCheckedBeforeSyntax')] + public function testParseFormatIsCheckedBeforeSyntax(string $value): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $value)); + + BigNumber::parse($value, [], 10); + } + + public static function providerParseFormatIsCheckedBeforeSyntax(): array + { + return [ + ['a/b'], + ['1/2/3'], + ['1ex'], + ['1.x'], + ]; + } + + public function testParseNullableWithNonNullInputSyntaxNotAllowed(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The decimal point syntax is not allowed.'); + + BigNumber::parseNullable('1.5', [], 10); + } + + /** + * Format-error messages truncate the rejected value: untrusted input of unbounded length must not be + * copied wholesale into exception messages and logs. + */ + #[DataProvider('providerParseTruncatesValueInErrorMessage')] + public function testParseTruncatesValueInErrorMessage(string $value, string $expectedValueInMessage): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage)); + + BigNumber::parse($value, NumberSyntax::ALL, 10); + } + + public static function providerParseTruncatesValueInErrorMessage(): array + { + return [ + 'integer form' => [str_repeat('1', 43) . 'X', str_repeat('1', 40) . '...'], + 'rational form' => [str_repeat('1', 50) . 'X/2', str_repeat('1', 40) . '...'], + 'digitless form' => ['.e' . str_repeat('3', 60), '.e' . str_repeat('3', 38) . '...'], + 'at the threshold, kept whole' => [str_repeat('1', 39) . 'X', str_repeat('1', 39) . 'X'], + 'just above the threshold' => [str_repeat('1', 40) . 'X', str_repeat('1', 40) . '...'], + ]; + } + + /** + * The truncation is a property of the exception, and applies to of() as well. + */ + public function testOfTruncatesValueInErrorMessage(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', str_repeat('1', 40) . '...')); + + BigNumber::of(str_repeat('1', 43) . 'X'); + } + + /** + * Format-error messages escape the rejected value in the repr style shared by Python, Go and Rust: + * untrusted input must not put raw control characters or invalid UTF-8 into exception messages and logs, + * while staying as readable as possible. ASCII controls are escaped as `\t`, `\n`, `\r` or `\xHH`; the + * backslash and the double quote are escaped as `\\` and `\"`, so the quotes delimiting the value are + * unambiguous and every backslash in the rendered value starts an escape. When the whole value is valid + * UTF-8, non-ASCII text is kept as-is, except for a short list of invisible characters commonly found in + * copy-pasted numbers, escaped as `\u{XXXX}`: kept, they would misleadingly look valid. When the value is + * not valid UTF-8, every non-ASCII byte is escaped as `\xHH`. Single characters in "not valid in base / + * alphabet" messages are rendered the same way, always quoted. + */ + #[DataProvider('providerParseEscapesValueInErrorMessage')] + public function testParseEscapesValueInErrorMessage(string $value, string $expectedValueInMessage): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage)); + + BigNumber::parse($value, NumberSyntax::ALL, 10); + } + + public static function providerParseEscapesValueInErrorMessage(): array + { + return [ + 'control character' => ["12\x0034", '12\x0034'], + 'terminal escape sequence' => ["\x1B[31m1", '\x1B[31m1'], + 'DEL' => ["1\x7F", '1\x7F'], + 'tab' => ["1\t2", '1\t2'], + 'carriage return and newline' => ["1\r\n", '1\r\n'], + 'backslash, escaped unambiguously' => ['1\x0A', '1\\\\x0A'], + 'spoofed invisible escape, rendered inert' => ['1\u{00A0}2', '1\\\\u{00A0}2'], + 'double quote, escaped unambiguously' => ['1"2', '1\"2'], + 'angle bracket, left alone' => ['1<2', '1<2'], + 'dollar sign, left alone' => ['$100', '$100'], + 'visible Latin-1 letter, kept' => ["1\u{E9}", "1\u{E9}"], + 'visible Unicode minus sign, kept' => ["1\u{2212}1", "1\u{2212}1"], + 'visible fullwidth digits, kept' => ["\u{FF11}\u{FF12}", "\u{FF11}\u{FF12}"], + 'visible astral emoji, kept' => ["1\u{1F600}", "1\u{1F600}"], + 'invisible no-break space, escaped' => ["1\u{00A0}000", '1\u{00A0}000'], + 'invisible narrow no-break space, escaped' => ["1\u{202F}000", '1\u{202F}000'], + 'invisible zero-width space, escaped' => ["1\u{200B}2", '1\u{200B}2'], + 'invisible byte order mark, escaped' => ["\u{FEFF}123", '\u{FEFF}123'], + 'other invisible characters, kept as-is' => ["1\u{2028}\u{202E}2", "1\u{2028}\u{202E}2"], + 'broken UTF-8: lone lead byte' => ["1\xC3", '1\xC3'], + 'broken UTF-8: lone continuation byte' => ["1\xA9", '1\xA9'], + 'broken UTF-8: overlong encoding' => ["1\xC0\xAF", '1\xC0\xAF'], + 'broken UTF-8: surrogate half' => ["1\xED\xA0\x80", '1\xED\xA0\x80'], + 'broken UTF-8 escapes every non-ASCII byte' => ["1\u{E9}2\xFF", '1\xC3\xA92\xFF'], + 'truncation drops a cut multibyte character' => [str_repeat('1', 39) . "\u{20AC}XXX", str_repeat('1', 39) . '...'], + 'truncation keeps a whole multibyte character' => [str_repeat('1', 37) . "\u{20AC}XX", str_repeat('1', 37) . "\u{20AC}..."], + ]; + } + + #[DataProvider('providerInvalidMaxDigits')] + public function testParseWithInvalidMaxDigits(int $maxDigits): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The maximum number of digits must be a positive integer.'); + + /** @phpstan-ignore argument.type */ + BigNumber::parse('1', allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + #[DataProvider('providerInvalidMaxDigits')] + public function testParseNullableWithNonNullInputAndInvalidMaxDigits(int $maxDigits): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The maximum number of digits must be a positive integer.'); + + /** @phpstan-ignore argument.type */ + BigNumber::parseNullable('1', allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + #[DataProvider('providerInvalidMaxDigits')] + public function testParseNullableWithNullInputAndInvalidMaxDigits(int $maxDigits): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The maximum number of digits must be a positive integer.'); + + /** @phpstan-ignore argument.type */ + BigNumber::parseNullable(null, allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + public static function providerInvalidMaxDigits(): array + { + return [ + [0], + [-1], + ]; + } + /** * @param list $values */ @@ -291,6 +646,55 @@ public static function providerSumThrowsRoundingNecessaryException(): array ]; } + /** + * Yields every subset of syntax features against every top-level form, as [value, syntax, expected value]. + * The expected value is null when the value uses a feature that is not allowed. + */ + private static function syntaxMatrix(): Generator + { + // Each value is listed with the exact syntax features it uses. + $values = [ + ['5', [], '5'], + ['1.5', [NumberSyntax::DecimalPoint], '1.5'], + ['5e3', [NumberSyntax::Exponent], '5000'], + ['1.5e1', [NumberSyntax::DecimalPoint, NumberSyntax::Exponent], '15'], + ['1/2', [NumberSyntax::Fraction], '1/2'], + ]; + + $syntaxes = [ + [], + [NumberSyntax::DecimalPoint], + [NumberSyntax::Exponent], + [NumberSyntax::Fraction], + [NumberSyntax::DecimalPoint, NumberSyntax::Exponent], + [NumberSyntax::DecimalPoint, NumberSyntax::Fraction], + [NumberSyntax::Exponent, NumberSyntax::Fraction], + [NumberSyntax::DecimalPoint, NumberSyntax::Exponent, NumberSyntax::Fraction], + ]; + + foreach ($syntaxes as $syntax) { + foreach ($values as [$value, $features, $expectedValue]) { + $allowed = true; + + foreach ($features as $feature) { + if (! in_array($feature, $syntax, true)) { + $allowed = false; + + break; + } + } + + $key = sprintf( + "'%s' with [%s]", + $value, + implode(', ', array_map(static fn (NumberSyntax $case) => $case->name, $syntax)), + ); + + yield $key => [$value, $syntax, $allowed ? $expectedValue : null]; + } + } + } + private static function generateVariations(string $number): Generator { $parts = explode('/', $number, 2); diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index 0f11cfb..6f9913e 100644 --- a/tests/BigRationalTest.php +++ b/tests/BigRationalTest.php @@ -12,6 +12,7 @@ use Brick\Math\Exception\InvalidArgumentException; use Brick\Math\Exception\NumberFormatException; use Brick\Math\Exception\RoundingNecessaryException; +use Brick\Math\NumberSyntax; use Brick\Math\RoundingMode; use Generator; use LogicException; @@ -175,6 +176,94 @@ public static function providerOfInvalidFormatThrowsException(): array ]; } + /** + * The digit limit applies to the number as parsed, before its conversion to BigRational. + * + * @param string $value The value to parse. + * @param int $digitCount The exact number of digits in $value; parsing must succeed with this limit. + * @param string $expected The expected rational result. + */ + #[DataProvider('providerParse')] + public function testParse(string $value, int $digitCount, string $expected): void + { + self::assertBigRationalEquals($expected, BigRational::parse($value, allowedSyntax: NumberSyntax::RATIONAL, maxDigits: $digitCount)); + } + + public static function providerParse(): array + { + return [ + ['22/7', 3, '22/7'], + ['020/040', 6, '1/2'], // leading zeros count as written digits + ]; + } + + /** + * @param string $value The value to parse. + * @param int $maxDigits The tightest failing limit: one less than the exact digit count of $value. + */ + #[DataProvider('providerParseExceeded')] + public function testParseExceeded(string $value, int $maxDigits): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessage("The number exceeds the maximum number of $maxDigits digits."); + + BigRational::parse($value, allowedSyntax: NumberSyntax::RATIONAL, maxDigits: $maxDigits); + } + + public static function providerParseExceeded(): Generator + { + // Every accepted row of the matrix above must be rejected at one digit less. + foreach (self::providerParse() as [$value, $digitCount]) { + if ($digitCount > 1) { + yield [$value, $digitCount - 1]; + } + } + } + + public function testParseWithZeroDenominator(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The denominator of a rational number must not be zero.'); + + BigRational::parse('2/0', NumberSyntax::RATIONAL, 10); + } + + public function testParseWithoutDecimalPointSyntax(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The decimal point syntax is not allowed.'); + + BigRational::parse('1.5', [NumberSyntax::Fraction], 10); + } + + /** + * The digit limit applies to the number as parsed, before its conversion to BigRational. + */ + public function testParseWithDecimalPointSyntaxConvertsExactValue(): void + { + self::assertBigRationalEquals('3/2', BigRational::parse('1.5', [NumberSyntax::DecimalPoint], 2)); + } + + /** + * `NumberSyntax::RATIONAL` accepts integers and fractions only. + */ + #[DataProvider('providerParseWithRationalSyntaxRejectsOtherNotations')] + public function testParseWithRationalSyntaxRejectsOtherNotations(string $value, string $expectedMessage): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact($expectedMessage); + + BigRational::parse($value, allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 10); + } + + public static function providerParseWithRationalSyntaxRejectsOtherNotations(): array + { + return [ + ['1.5', 'The decimal point syntax is not allowed.'], + ['1e2', 'The exponent syntax is not allowed.'], + ]; + } + public function testZero(): void { self::assertBigRationalEquals('0', BigRational::zero()); diff --git a/tests/NumberSyntaxTest.php b/tests/NumberSyntaxTest.php new file mode 100644 index 0000000..d04235d --- /dev/null +++ b/tests/NumberSyntaxTest.php @@ -0,0 +1,26 @@ + Date: Sat, 22 Aug 2026 13:17:17 +0200 Subject: [PATCH 09/10] Rework exponent handling in _parse() --- src/BigNumber.php | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index a4f9d9f..aa2ee7d 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -25,7 +25,6 @@ use function str_contains; use function str_repeat; use function strlen; -use function substr; use const FILTER_VALIDATE_INT; use const PHP_INT_MAX; @@ -764,32 +763,26 @@ private static function _parse(string $value, array $allowedSyntax, int $maxDigi throw NumberFormatException::syntaxNotAllowed(NumberSyntax::Exponent); } - $fractional ??= ''; + if ($exponent === null) { + $exponent = 0; + } else { + $exponentSign = $exponent[0] === '-' ? '-' : ''; + $exponent = ltrim(ltrim($exponent, '+-'), '0'); - if ($exponent !== null) { - if ($exponent[0] === '-') { - $exponent = ltrim(substr($exponent, 1), '0') ?: '0'; - $exponent = filter_var($exponent, FILTER_VALIDATE_INT); - if ($exponent !== false) { - $exponent = -$exponent; - } + if ($exponent === '') { + $exponent = 0; } else { - if ($exponent[0] === '+') { - $exponent = substr($exponent, 1); + $exponent = filter_var($exponentSign . $exponent, FILTER_VALIDATE_INT); + + if ($exponent === false) { + throw NumberFormatException::exponentTooLarge(); } - $exponent = ltrim($exponent, '0') ?: '0'; - $exponent = filter_var($exponent, FILTER_VALIDATE_INT); } - } else { - $exponent = 0; } - if ($exponent === false) { - throw NumberFormatException::exponentTooLarge(); - } + $fractional ??= ''; $unscaledValue = self::cleanUp($sign, $integral . $fractional); - $scale = strlen($fractional) - $exponent; // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) From 39057fd206a403fd5450cb30ee457a01341163c0 Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Sat, 22 Aug 2026 13:20:39 +0200 Subject: [PATCH 10/10] Invert logic in _parse() --- src/BigNumber.php | 98 ++++++++++++++++++++++++----------------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/src/BigNumber.php b/src/BigNumber.php index aa2ee7d..ab078fb 100644 --- a/src/BigNumber.php +++ b/src/BigNumber.php @@ -754,74 +754,76 @@ private static function _parse(string $value, array $allowedSyntax, int $maxDigi $integral = '0'; } - if ($point !== null || $exponent !== null) { - if ($point !== null && ! in_array(NumberSyntax::DecimalPoint, $allowedSyntax, true)) { - throw NumberFormatException::syntaxNotAllowed(NumberSyntax::DecimalPoint); + if ($point === null && $exponent === null) { + // Integer number. + if ($writtenDigits > $maxDigits) { + throw NumberFormatException::tooManyDigits($maxDigits); } - if ($exponent !== null && ! in_array(NumberSyntax::Exponent, $allowedSyntax, true)) { - throw NumberFormatException::syntaxNotAllowed(NumberSyntax::Exponent); - } + $integral = self::cleanUp($sign, $integral); - if ($exponent === null) { - $exponent = 0; - } else { - $exponentSign = $exponent[0] === '-' ? '-' : ''; - $exponent = ltrim(ltrim($exponent, '+-'), '0'); + return new BigInteger($integral); + } - if ($exponent === '') { - $exponent = 0; - } else { - $exponent = filter_var($exponentSign . $exponent, FILTER_VALIDATE_INT); + // Decimal number. + if ($point !== null && ! in_array(NumberSyntax::DecimalPoint, $allowedSyntax, true)) { + throw NumberFormatException::syntaxNotAllowed(NumberSyntax::DecimalPoint); + } - if ($exponent === false) { - throw NumberFormatException::exponentTooLarge(); - } - } - } + if ($exponent !== null && ! in_array(NumberSyntax::Exponent, $allowedSyntax, true)) { + throw NumberFormatException::syntaxNotAllowed(NumberSyntax::Exponent); + } - $fractional ??= ''; + if ($exponent === null) { + $exponent = 0; + } else { + $exponentSign = $exponent[0] === '-' ? '-' : ''; + $exponent = ltrim(ltrim($exponent, '+-'), '0'); - $unscaledValue = self::cleanUp($sign, $integral . $fractional); - $scale = strlen($fractional) - $exponent; + if ($exponent === '') { + $exponent = 0; + } else { + $exponent = filter_var($exponentSign . $exponent, FILTER_VALIDATE_INT); - // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) - if (! is_int($scale)) { - throw NumberFormatException::exponentTooLarge(); + if ($exponent === false) { + throw NumberFormatException::exponentTooLarge(); + } } + } - $digits = strlen($unscaledValue) - (int) ($unscaledValue[0] === '-'); + $fractional ??= ''; - if ($scale < 0 && $unscaledValue !== '0') { - // The unscaled value is padded with -$scale zeros below. - $count = $digits - $scale; - } else { - // The fractional digits, plus at least a zero integer part. - $count = max($digits, $scale + 1); - } + $unscaledValue = self::cleanUp($sign, $integral . $fractional); + $scale = strlen($fractional) - $exponent; - // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) - if (! is_int($count) || $count > $maxDigits || $writtenDigits > $maxDigits) { - throw NumberFormatException::tooManyDigits($maxDigits); - } + // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) + if (! is_int($scale)) { + throw NumberFormatException::exponentTooLarge(); + } - if ($scale < 0) { - if ($unscaledValue !== '0') { - $unscaledValue .= str_repeat('0', Safe::neg($scale)); - } - $scale = 0; - } + $digits = strlen($unscaledValue) - (int) ($unscaledValue[0] === '-'); - return new BigDecimal($unscaledValue, $scale); + if ($scale < 0 && $unscaledValue !== '0') { + // The unscaled value is padded with -$scale zeros below. + $count = $digits - $scale; + } else { + // The fractional digits, plus at least a zero integer part. + $count = max($digits, $scale + 1); } - if ($writtenDigits > $maxDigits) { + // @phpstan-ignore function.alreadyNarrowedType (may overflow to float) + if (! is_int($count) || $count > $maxDigits || $writtenDigits > $maxDigits) { throw NumberFormatException::tooManyDigits($maxDigits); } - $integral = self::cleanUp($sign, $integral); + if ($scale < 0) { + if ($unscaledValue !== '0') { + $unscaledValue .= str_repeat('0', Safe::neg($scale)); + } + $scale = 0; + } - return new BigInteger($integral); + return new BigDecimal($unscaledValue, $scale); } /**