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 6dd05ef1c44b7228158df912090fcd5c3a109c81 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 | 51 ++++++++++++++++--------- tests/BigDecimalTest.php | 14 ++++--- tests/BigIntegerTest.php | 38 ++++++++++++------ tests/BigNumberTest.php | 29 ++++++++++---- tests/BigRationalTest.php | 15 ++++---- 5 files changed, 98 insertions(+), 49 deletions(-) diff --git a/src/Exception/NumberFormatException.php b/src/Exception/NumberFormatException.php index 4d61c47..616cd81 100644 --- a/src/Exception/NumberFormatException.php +++ b/src/Exception/NumberFormatException.php @@ -6,10 +6,10 @@ use RuntimeException; -use function dechex; use function ord; use function sprintf; -use function strtoupper; +use function strlen; +use function substr; /** * Exception thrown when attempting to create a number from a string with an invalid format. @@ -35,7 +35,7 @@ public static function invalidFormat(string $value): self { return new self(sprintf( 'Value "%s" does not represent a valid number.', - $value, + self::truncateAndEscape($value), )); } @@ -49,8 +49,8 @@ public static function invalidFormat(string $value): self public static function charNotInAlphabet(string $char): self { return new self(sprintf( - 'Character %s is not valid in the given alphabet.', - self::charToString($char), + 'Character "%s" is not valid in the given alphabet.', + self::escapeChar($char), )); } @@ -62,8 +62,8 @@ public static function charNotInAlphabet(string $char): self public static function charNotValidInBase(string $char, int $base): self { return new self(sprintf( - 'Character %s is not valid in base %d.', - self::charToString($char), + 'Character "%s" is not valid in base %d.', + self::escapeChar($char), $base, )); } @@ -111,20 +111,37 @@ public static function zeroDenominator(): self /** * @pure */ - private static function charToString(string $char): string + private static function truncateAndEscape(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; - } + $escaped = ''; + $length = strlen($value); - return '0x' . $char; + for ($i = 0; $i < $length; $i++) { + $escaped .= self::escapeChar($value[$i]); } - return '"' . $char . '"'; + return $escaped; + } + + /** + * @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, + }; } } diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php index 66bffdb..d9a3b77 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, if 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..ffb6e61 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, if 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,19 @@ 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.'], ]; } diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php index 6369435..cde9426 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, if 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'], @@ -153,6 +157,15 @@ public static function providerOfInvalidFormatThrowsException(): array [' 1/2'], ['1/2 '], ['/'], + // Special chars. + ["12\u{0663}4", '12\xD9\xA34'], + ["1\u{00A0}000", '1\xC2\xA0000'], + ["\0\x7f\x80", '\x00\x7F\x80'], + ["1 \r\n\t", '1 \r\n\t'], + // Exception message truncates value at 40 chars. + [str_repeat('a', 41), str_repeat('a', 40) . '...'], + // The cut falls between the 2 bytes of the U+00A0 sequence. + [str_repeat('1', 39) . "\u{00A0}5", str_repeat('1', 39) . '\xC2...'], ]; } diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index c60ddfc..9cd802c 100644 --- a/tests/BigRationalTest.php +++ b/tests/BigRationalTest.php @@ -145,15 +145,16 @@ public function testOfEmptyStringThrowsException(): void } /** - * @param string $string An invalid string representation. + * @param string $value An invalid string representation. + * @param string|null $expectedValueInMessage The value as rendered in the message, if it differs from $value. */ #[DataProvider('providerOfInvalidFormatThrowsException')] - public function testOfInvalidFormatThrowsException(string $string): 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.', $string)); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); - BigRational::of($string); + BigRational::of($value); } public static function providerOfInvalidFormatThrowsException(): array @@ -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 1e5a8c797f3f4eb810de72e5b1a677ec6404b8d7 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 | 15 + src/BigNumber.php | 157 +++++++- src/Exception/InvalidArgumentException.php | 10 + src/Exception/NumberFormatException.php | 28 ++ src/NumberSyntax.php | 80 ++++ tests/BigDecimalTest.php | 29 ++ tests/BigIntegerTest.php | 27 ++ tests/BigNumberTest.php | 410 +++++++++++++++++++++ tests/BigRationalTest.php | 15 + tests/NumberSyntaxTest.php | 26 ++ 10 files changed, 793 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..ef7f244 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ 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 +> ``` + #### Immutability & chaining The `BigInteger`, `BigDecimal` and `BigRational` classes are immutable: their value never changes, @@ -148,6 +159,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..b389887 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 more digits, as in `BigDecimal::parse('1/8', ...)` where `1/8` counts 2 digits, but + * the resulting `0.125` counts 4. + * + * @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 616cd81..0f91e3f 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; @@ -98,6 +99,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(RoundingNecessaryException::class); + $this->expectExceptionMessageExact('This rational number has a non-terminating decimal expansion and cannot be represented as a decimal without rounding.'); + + BigDecimal::parse('1/3', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2); + } + + public function testParseNullableConvertibleValue(): void + { + // 2 digits as parsed, although the converted result has 4 + self::assertBigDecimalEquals('0.125', BigDecimal::parseNullable('1/8', NumberSyntax::RATIONAL, 2)); + } + + public function testParseNullableNonConvertibleValueThrowsException(): void + { + $this->expectException(RoundingNecessaryException::class); + $this->expectExceptionMessageExact('This rational number has a non-terminating decimal expansion and cannot be represented as a decimal without rounding.'); + + BigDecimal::parseNullable('1/7', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2); + } + /** * @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 ffb6e61..8ee99f0 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,32 @@ public static function providerOfNonConvertibleValueThrowsException(): array ]; } + public function testParseConvertibleValue(): void + { + self::assertBigIntegerEquals('123', BigInteger::parse('123.00', NumberSyntax::DECIMAL, 5)); + } + + 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); + } + + public function testParseNullableConvertibleValue(): void + { + self::assertBigIntegerEquals('9', BigInteger::parseNullable('9.0', NumberSyntax::DECIMAL, 2)); + } + + public function testParseNullableNonConvertibleValueThrowsException(): void + { + $this->expectException(RoundingNecessaryException::class); + $this->expectExceptionMessageExact('This rational number cannot be represented as an integer without rounding.'); + + BigInteger::parseNullable('1/7', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2); + } + /** * @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 cde9426..b012c30 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 count; use function explode; +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; + +use const PHP_INT_MAX; /** * Unit tests for class BigNumber. @@ -118,6 +126,19 @@ public function testOfInvalidFormatThrowsException(string $value, ?string $expec BigNumber::of($value); } + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, if it differs from $value. + */ + #[DataProvider('providerOfInvalidFormatThrowsException')] + public function testOfNullableWithInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); + + BigNumber::ofNullable($value); + } + public static function providerOfInvalidFormatThrowsException(): array { return [ @@ -192,6 +213,341 @@ 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, string $expectedClass, string $expectedValue, int $digitCount): void + { + $result = BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $digitCount); + + self::assertSame($expectedClass, $result::class); + self::assertSame($expectedValue, $result->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->expectExceptionMessageExact("The number exceeds the maximum number of $maxDigits digits."); + + BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + #[DataProvider('providerParse')] + public function testParseNullableWithNonNullInput(string $value, string $expectedClass, string $expectedValue, int $digitCount): void + { + $result = BigNumber::parseNullable($value, allowedSyntax: NumberSyntax::ALL, maxDigits: $digitCount); + + self::assertNotNull($result); + self::assertSame($expectedClass, $result::class); + self::assertSame($expectedValue, $result->toString()); + } + + #[DataProvider('providerParseExceeded')] + public function testParseNullableWithNonNullInputExceeded(string $value, int $maxDigits): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact("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 function testParseEmptyStringThrowsException(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The number must not be empty.'); + + BigNumber::parse('', allowedSyntax: [], maxDigits: 1); + } + + public function testParseNullableWithEmptyStringThrowsException(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The number must not be empty.'); + + BigNumber::parseNullable('', allowedSyntax: [], maxDigits: 1); + } + + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, if it differs from $value. + */ + #[DataProvider('providerOfInvalidFormatThrowsException')] + public function testParseInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); + + BigNumber::parse($value, allowedSyntax: [], maxDigits: 1); + } + + /** + * @param string $value The invalid value. + * @param string|null $expectedValueInMessage The value as rendered in the message, if it differs from $value. + */ + #[DataProvider('providerOfInvalidFormatThrowsException')] + public function testParseNullableWithInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value)); + + BigNumber::parseNullable($value, allowedSyntax: [], maxDigits: 1); + } + + /** + * @return Generator, string, int}> + */ + public static function providerParse(): Generator + { + // Variations (sign, leading zeros) will be generated for each input. + $values = [ + ['0', BigInteger::class, '0'], + ['1', BigInteger::class, '1'], + ['23', BigInteger::class, '23'], + ['1000', BigInteger::class, '1000'], + ['1' . str_repeat('0', 100), BigInteger::class, '1' . str_repeat('0', 100)], + ['.0', BigDecimal::class, '0.0'], + ['.000', BigDecimal::class, '0.000'], + ['0e5', BigDecimal::class, '0'], + ['0e100', BigDecimal::class, '0'], + ['0e-2', BigDecimal::class, '0.00'], + ['.001', BigDecimal::class, '0.001'], + ['.0001', BigDecimal::class, '0.0001'], + ['.0010', BigDecimal::class, '0.0010'], + ['5.', BigDecimal::class, '5'], + ['5.e3', BigDecimal::class, '5000'], + ['5.e-3', BigDecimal::class, '0.005'], + ['.5e3', BigDecimal::class, '500'], + ['.5e-3', BigDecimal::class, '0.0005'], + ['123.45', BigDecimal::class, '123.45'], + ['1e3', BigDecimal::class, '1000'], + ['1.0000e2', BigDecimal::class, '100.00'], + ['1.0000e3', BigDecimal::class, '1000.0'], + ['1.000e3', BigDecimal::class, '1000'], + ['1.000e4', BigDecimal::class, '10000'], + ['1.2e-2', BigDecimal::class, '0.012'], + ['1.2e-1', BigDecimal::class, '0.12'], + ['1.2e0', BigDecimal::class, '1.2'], + ['1.2e1', BigDecimal::class, '12'], + ['1.2e2', BigDecimal::class, '120'], + ['1.2e3', BigDecimal::class, '1200'], + ['1e-9', BigDecimal::class, '0.000000001'], + ['1e100', BigDecimal::class, '1' . str_repeat('0', 100)], + ['0.00000000001e11', BigDecimal::class, '1'], + ['1e' . str_repeat('0', 20) . '1', BigDecimal::class, '10'], + ['1/3', BigRational::class, '1/3'], + ['22/7', BigRational::class, '22/7'], + ['2/4', BigRational::class, '1/2'], + ['7/3', BigRational::class, '7/3'], + ['0/5', BigRational::class, '0'], + [sprintf('9%s/3%s', str_repeat('0', 100), str_repeat('0', 100)), BigRational::class, '3'], + ]; + + foreach ($values as [$number, $expectedClass, $expectedValue]) { + $isZero = preg_match('/[1-9]/', $expectedValue) !== 1; + $resultDigitCount = self::countDigits($expectedValue); + + foreach (self::generateVariations($number) as $variation) { + $negated = ! $isZero && $variation[0] === '-'; + $writtenDigitCount = self::countDigits($variation); + + yield [ + $variation, + $expectedClass, + $negated ? '-' . $expectedValue : $expectedValue, + max($resultDigitCount, $writtenDigitCount), + ]; + } + } + } + + /** + * @return Generator + */ + public static function providerParseExceeded(): Generator + { + // Every accepted row of the main matrix must be rejected at one digit less. + foreach (self::providerParse() as [$value, $_expectedClass, $_expectedValue, $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 + yield ['.5e-1000000000', 1_000_000_001]; // 1_000_000_002 digits + } + + /** + * An exponent too large to process must be reported as such. + */ + #[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.'); + + BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: 100); + } + + /** + * @return list + */ + 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.'); + + BigNumber::parse($value, allowedSyntax: NumberSyntax::ALL, maxDigits: PHP_INT_MAX); + } + + /** + * @return list + */ + public static function providerParseDigitCountOverflow(): array + { + return [ + ['1e' . PHP_INT_MAX], + ['1e-' . PHP_INT_MAX], + ]; + } + + public function testParseWithZeroDenominator(): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageExact('The denominator of a rational number must not be zero.'); + + BigNumber::parse('2/0', NumberSyntax::ALL, 10); + } + + /** + * @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); + } + + /** + * @param list $syntax + */ + #[DataProvider('providerParseSyntaxAllowed')] + public function testParseNullableSyntaxAllowed(string $value, array $syntax, string $expectedValue): void + { + $number = BigNumber::parseNullable($value, $syntax, 10); + + self::assertNotNull($number); + self::assertSame($expectedValue, $number->toString()); + } + + /** + * @param list $syntax + */ + #[DataProvider('providerParseSyntaxNotAllowed')] + public function testParseNullableSyntaxNotAllowed(string $value, array $syntax): void + { + $this->expectException(NumberFormatException::class); + $this->expectExceptionMessageMatches('/^The (decimal point|exponent|fraction) syntax is not allowed\.$/'); + + BigNumber::parseNullable($value, $syntax, 10); + } + + /** + * @return Generator, string}> + */ + public static function providerParseSyntaxAllowed(): Generator + { + foreach (self::syntaxMatrix() as [$value, $syntax, $expectedValue]) { + if ($expectedValue !== null) { + yield [$value, $syntax, $expectedValue]; + } + } + } + + /** + * @return Generator}> + */ + public static function providerParseSyntaxNotAllowed(): Generator + { + foreach (self::syntaxMatrix() as [$value, $syntax, $expectedValue]) { + if ($expectedValue === null) { + yield [$value, $syntax]; + } + } + } + + #[DataProvider('providerInvalidMaxDigits')] + public function testParseWithInvalidMaxDigits(int $maxDigits): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('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 testParseNullableWithInvalidMaxDigits(int $maxDigits): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The maximum number of digits must be a positive integer.'); + + /** @phpstan-ignore argument.type */ + BigNumber::parseNullable('1', allowedSyntax: NumberSyntax::ALL, maxDigits: $maxDigits); + } + + public static function providerInvalidMaxDigits(): array + { + return [ + [0], + [-1], + ]; + } + /** * @param list $values */ @@ -300,6 +656,60 @@ public static function providerSumThrowsRoundingNecessaryException(): array ]; } + /** + * Yields every combination of number syntax and syntax sets, as [value, syntax, expected value]. + * The expected value is null when the value uses a feature that is not allowed. + * + * @return Generator, string|null}> + */ + 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'], + ]; + + // All possible syntax sets. + $syntaxSets = [ + [], + [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 ($syntaxSets as $syntaxSet) { + foreach ($values as [$value, $syntaxes, $expectedValue]) { + $allowed = true; + + foreach ($syntaxes as $syntax) { + if (! in_array($syntax, $syntaxSet, true)) { + $allowed = false; + + break; + } + } + + yield [$value, $syntaxSet, $allowed ? $expectedValue : null]; + } + } + } + + private static function countDigits(string $number): int + { + return strlen((string) preg_replace('/[^0-9]/', '', $number)); + } + + /** + * @return Generator + */ private static function generateVariations(string $number): Generator { $parts = explode('/', $number, 2); diff --git a/tests/BigRationalTest.php b/tests/BigRationalTest.php index 9cd802c..f7c155c 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,20 @@ public static function providerOfInvalidFormatThrowsException(): array ]; } + public function testParse(): void + { + self::assertBigRationalEquals('3/2', BigRational::parse('1.5', NumberSyntax::DECIMAL, 2)); + } + + public function testParseNullable(): void + { + // 2 digits as parsed, although the converted result has 4 + $result = BigRational::parseNullable('3.7', NumberSyntax::DECIMAL, 2); + + self::assertNotNull($result); + self::assertBigRationalEquals('37/10', $result); + } + 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 b389887..aeb37f1 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 a676baf98d4b44a827f23f7cf88758a40d8a9be1 Mon Sep 17 00:00:00 2001 From: Benjamin Morel Date: Tue, 25 Aug 2026 23:25:25 +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 aeb37f1..fbfa6fb 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); } /**