diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fad5e7..5201348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented in this file. +## 8.4.2 - Unreleased + +### Fixed + +- `$decimals` above 100 throws `InvalidArgumentException` in `Number::format()`, `Number::compact()`, `Number::percentage()`, `Bytes::format()`, `Money::format()` and `Money::compact()`. Very large values used to exhaust memory with a fatal error, or return wrong digits with a locale: `Number::format(1.5, 100000000, false, 'en_US')` returned `"0"`. +- Exception messages quote at most 50 bytes of the value they reject, so a huge input no longer ends up whole in a message or a log. + +### Compatibility + +- Decimals from 0 to 100 format exactly as before. + ## 8.4.1 - 2026-10-11 ### Changed diff --git a/MAINTAINING.md b/MAINTAINING.md index 7cb0228..92a8069 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -22,7 +22,7 @@ One branch per PHP minor, each requiring exactly that minor (`"php": ">=8.N <8.N - Without a `$locale`, output comes from the built-in English formatter and is identical on every machine, with or without `ext-intl`. - With a `$locale`, `ext-intl` is used; without it, `MissingIntlExtension` is thrown. Invalid locales never leak `ValueError` or `IntlException`. - Rounding is half away from zero everywhere, a rounded value carries to the next unit (`999 999 B` is `1 MB`), and no formatter prints negative zero. -- Invalid input throws the native `InvalidArgumentException`. +- Invalid input throws the native `InvalidArgumentException`. `$decimals` accepts 0 to 100, and exception messages quote at most 50 bytes of the rejected value. - Output changes are behavior changes: they need a changelog entry and must not land in a patch on an existing line unless they fix a bug. ## Package-specific constraints diff --git a/docs/sections/03-basic-usage.md b/docs/sections/03-basic-usage.md index deaa33c..04a74c4 100644 --- a/docs/sections/03-basic-usage.md +++ b/docs/sections/03-basic-usage.md @@ -29,5 +29,5 @@ Money::format(1234.5, 'USD', decimals: 0); // "USD 1,235" - **Rounding** is half away from zero, like `number_format()`: `Number::format(2.5)` is `"3"` and `Number::format(-2.5)` is `"-3"`. - **No negative zero.** A negative value that rounds to zero prints as zero: `Number::format(-0.001, 2)` is `"0.00"`. -- **Invalid input throws `InvalidArgumentException`:** `INF`, `NAN`, negative decimals, a malformed currency code, an unparsable size, or a date string PHP cannot read. +- **Invalid input throws `InvalidArgumentException`:** `INF`, `NAN`, decimals below 0 or above 100, a malformed currency code, an unparsable size, or a date string PHP cannot read. - **No state.** There is no global configuration; everything a call needs is in its arguments. diff --git a/docs/sections/11-api-reference.md b/docs/sections/11-api-reference.md index 2b6b6e8..b437cd0 100644 --- a/docs/sections/11-api-reference.md +++ b/docs/sections/11-api-reference.md @@ -53,4 +53,4 @@ Every method is `public static`. Methods marked *throws* raise `InvalidArgumentE | Class | Extends | Thrown when | | --- | --- | --- | | `Pharaonic\Readable\Exceptions\MissingIntlExtension` | `RuntimeException` | A `$locale` is passed but `ext-intl` isn't loaded. | -| `InvalidArgumentException` (PHP) | `LogicException` | `INF`/`NAN`, negative decimals, `parts` or `limit` below 1, a malformed currency code, an unparsable size or date, invalid UTF-8, or a locale Intl rejects. | +| `InvalidArgumentException` (PHP) | `LogicException` | `INF`/`NAN`, decimals below 0 or above 100, `parts` or `limit` below 1, a malformed currency code, an unparsable size or date, invalid UTF-8, or a locale Intl rejects. | diff --git a/src/Bytes.php b/src/Bytes.php index 5a9b6a0..86a3fc2 100644 --- a/src/Bytes.php +++ b/src/Bytes.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Pharaonic\Readable\Support\Assert; +use Pharaonic\Readable\Support\Excerpt; /** * Readable byte sizes. @@ -67,7 +68,7 @@ public static function parse(string $size): int $pattern = '/^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(?:([kmgtpezy])(i)?b|b|bytes?)?\s*$/i'; if (preg_match($pattern, $size, $match) !== 1) { - throw new InvalidArgumentException(sprintf('Cannot parse "%s" as a byte size.', $size)); + throw new InvalidArgumentException(sprintf('Cannot parse %s as a byte size.', Excerpt::quote($size))); } $multiplier = 1; @@ -85,7 +86,7 @@ public static function parse(string $size): int // 2^63 is the first float above PHP_INT_MAX. if ($bytes >= 9.2233720368547758E18 || $bytes < -9.2233720368547758E18) { - throw new InvalidArgumentException(sprintf('"%s" is too large to be represented in bytes.', $size)); + throw new InvalidArgumentException(sprintf('%s is too large to be represented in bytes.', Excerpt::quote($size))); } return (int) $bytes; @@ -117,6 +118,6 @@ private static function multiply(string $number, int|float $multiplier, string $ } } - throw new InvalidArgumentException(sprintf('"%s" is too large to be represented in bytes.', $size)); + throw new InvalidArgumentException(sprintf('%s is too large to be represented in bytes.', Excerpt::quote($size))); } } diff --git a/src/Duration.php b/src/Duration.php index b75e960..0c98119 100644 --- a/src/Duration.php +++ b/src/Duration.php @@ -8,6 +8,7 @@ use DateTimeInterface; use Exception; use InvalidArgumentException; +use Pharaonic\Readable\Support\Excerpt; /** * Readable durations in English: "1 hour 1 minute", or "1h 1m" in short form. @@ -141,7 +142,7 @@ private static function date(DateTimeInterface|int|string $value): DateTimeInter try { return new DateTimeImmutable($value); } catch (Exception $exception) { - throw new InvalidArgumentException(sprintf('"%s" is not a valid date.', $value), 0, $exception); + throw new InvalidArgumentException(sprintf('%s is not a valid date.', Excerpt::quote($value)), 0, $exception); } } } diff --git a/src/Exceptions/MissingIntlExtension.php b/src/Exceptions/MissingIntlExtension.php index f7da8f1..3bc5177 100644 --- a/src/Exceptions/MissingIntlExtension.php +++ b/src/Exceptions/MissingIntlExtension.php @@ -4,6 +4,7 @@ namespace Pharaonic\Readable\Exceptions; +use Pharaonic\Readable\Support\Excerpt; use RuntimeException; /** @@ -14,9 +15,9 @@ final class MissingIntlExtension extends RuntimeException public static function forLocale(string $locale): self { return new self(sprintf( - 'Formatting for locale "%s" requires the intl extension. ' + 'Formatting for locale %s requires the intl extension. ' . 'Install ext-intl or omit the locale to use the built-in English formatter.', - $locale, + Excerpt::quote($locale), )); } } diff --git a/src/Money.php b/src/Money.php index 2361eef..0a1e92a 100644 --- a/src/Money.php +++ b/src/Money.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Pharaonic\Readable\Support\Assert; +use Pharaonic\Readable\Support\Excerpt; use Pharaonic\Readable\Support\Intl; /** @@ -89,7 +90,7 @@ private static function currency(string $currency): string if (preg_match('/^[A-Z]{3}$/', $code) !== 1) { throw new InvalidArgumentException( - sprintf('"%s" is not a three-letter ISO 4217 currency code.', $currency), + sprintf('%s is not a three-letter ISO 4217 currency code.', Excerpt::quote($currency)), ); } diff --git a/src/Support/Assert.php b/src/Support/Assert.php index 066283a..d80edcd 100644 --- a/src/Support/Assert.php +++ b/src/Support/Assert.php @@ -11,6 +11,11 @@ */ final class Assert { + /** + * Far beyond any display need. Larger values exhaust memory in number_format() or make Intl return wrong digits. + */ + private const int MAX_DECIMALS = 100; + public static function finite(int|float $number): void { if (is_float($number) && !is_finite($number)) { @@ -20,8 +25,10 @@ public static function finite(int|float $number): void public static function decimals(int $decimals): void { - if ($decimals < 0) { - throw new InvalidArgumentException(sprintf('Decimals must be zero or greater, %d given.', $decimals)); + if ($decimals < 0 || $decimals > self::MAX_DECIMALS) { + throw new InvalidArgumentException( + sprintf('Decimals must be between 0 and %d, %d given.', self::MAX_DECIMALS, $decimals), + ); } } } diff --git a/src/Support/Excerpt.php b/src/Support/Excerpt.php new file mode 100644 index 0000000..2ea6010 --- /dev/null +++ b/src/Support/Excerpt.php @@ -0,0 +1,26 @@ +setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFUP); diff --git a/tests/Pest.php b/tests/Pest.php index 61c3978..388c9e2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -14,3 +14,14 @@ function withoutIntl(): bool { return ! extension_loaded('intl'); } + +function thrownBy(callable $callback): Throwable +{ + try { + $callback(); + } catch (Throwable $exception) { + return $exception; + } + + throw new LogicException('Nothing was thrown.'); +} diff --git a/tests/Unit/BytesTest.php b/tests/Unit/BytesTest.php index acbee71..d12c146 100644 --- a/tests/Unit/BytesTest.php +++ b/tests/Unit/BytesTest.php @@ -120,3 +120,28 @@ 'one yotta overflows' => ['1 YB'], 'huge' => ['1000 YB'], ])->throws(InvalidArgumentException::class); + +it('rejects more than 100 decimals', function () { + Bytes::format(1500, 101); +})->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100, 101 given.'); + +it('quotes a short unparsable size whole in the error message', function () { + expect(thrownBy(fn() => Bytes::parse('10 K'))->getMessage())->toBe('Cannot parse "10 K" as a byte size.'); +}); + +it('cuts a long unparsable size in the error message', function () { + expect(thrownBy(fn() => Bytes::parse(str_repeat('x', 1000000)))->getMessage()) + ->toBe('Cannot parse "' . str_repeat('x', 50) . '…" as a byte size.'); +}); + +it('cuts a long oversized size in the error message', function () { + expect(thrownBy(fn() => Bytes::parse(str_repeat('9', 1000)))->getMessage()) + ->toBe('"' . str_repeat('9', 50) . '…" is too large to be represented in bytes.'); +}); + +it('never cuts a multibyte character in the error message', function () { + $message = thrownBy(fn() => Bytes::parse('x' . str_repeat('é', 100)))->getMessage(); + + expect(mb_check_encoding($message, 'UTF-8'))->toBeTrue() + ->and($message)->toBe('Cannot parse "x' . str_repeat('é', 24) . '…" as a byte size.'); +}); diff --git a/tests/Unit/DurationTest.php b/tests/Unit/DurationTest.php index 0bcd21d..b0f77e0 100644 --- a/tests/Unit/DurationTest.php +++ b/tests/Unit/DurationTest.php @@ -114,3 +114,8 @@ it('rejects an invalid date', function () { Duration::between('not a date', 'now'); })->throws(InvalidArgumentException::class); + +it('cuts a long invalid date in the error message', function () { + expect(thrownBy(fn() => Duration::between(str_repeat('x', 1000000), 'now'))->getMessage()) + ->toBe('"' . str_repeat('x', 50) . '…" is not a valid date.'); +}); diff --git a/tests/Unit/MoneyTest.php b/tests/Unit/MoneyTest.php index 2c5cd6b..4f88be0 100644 --- a/tests/Unit/MoneyTest.php +++ b/tests/Unit/MoneyTest.php @@ -92,3 +92,15 @@ 'CLF' => ['CLF', 4], 'lowercase' => ['jpy', 0], ]); + +it('rejects more than 100 decimals', function (string $method, array $arguments) { + Money::$method(...$arguments); +})->with([ + 'format' => ['format', [1, 'USD', 101]], + 'compact' => ['compact', [1, 'USD', 101]], +])->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100, 101 given.'); + +it('cuts a long currency code in the error message', function () { + expect(thrownBy(fn() => Money::format(1, str_repeat('X', 1000)))->getMessage()) + ->toBe('"' . str_repeat('X', 50) . '…" is not a three-letter ISO 4217 currency code.'); +}); diff --git a/tests/Unit/NumberTest.php b/tests/Unit/NumberTest.php index f9391b1..88a7462 100644 --- a/tests/Unit/NumberTest.php +++ b/tests/Unit/NumberTest.php @@ -233,3 +233,40 @@ it('needs ext-intl to spell a number with a locale', function () { Number::spell(1, 'en'); })->skip(! withoutIntl(), 'ext-intl is installed.')->throws(MissingIntlExtension::class); + +it('accepts up to 100 decimals', function () { + expect(Number::format(1.5, 100))->toBe('1.5' . str_repeat('0', 99)) + ->and(Number::percentage(0.5, 100))->toBe('50.' . str_repeat('0', 100) . '%') + ->and(Number::compact(1500, 100))->toBe('1.5K'); +}); + +it('rejects more than 100 decimals', function (string $method, array $arguments) { + Number::$method(...$arguments); +})->with([ + 'format' => ['format', [1.5, 101]], + 'compact' => ['compact', [1.5, 101]], + 'percentage' => ['percentage', [0.5, 101]], + 'int max' => ['format', [1.5, PHP_INT_MAX]], +])->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100'); + +it('rejects more than 100 decimals with a locale', function () { + Number::format(1.5, 101, false, 'en_US'); +})->skip(withoutIntl(), 'ext-intl is not installed.')->throws(InvalidArgumentException::class); + +it('cuts a long invalid locale in the error message', function () { + $exception = thrownBy(fn() => Number::format(1, 0, false, str_repeat('x', 10000))); + + // Older ICU builds accept any locale and fall back to the root locale. + if ($exception instanceof LogicException && $exception->getMessage() === 'Nothing was thrown.') { + expect(true)->toBeTrue(); + + return; + } + + expect($exception->getMessage())->toBe('"' . str_repeat('x', 50) . '…" is not a valid locale.'); +})->skip(withoutIntl(), 'ext-intl is not installed.'); + +it('cuts a long locale in the missing intl message', function () { + expect(thrownBy(fn() => Number::format(1, 0, false, str_repeat('x', 10000)))->getMessage()) + ->toStartWith('Formatting for locale "' . str_repeat('x', 50) . '…" requires the intl extension.'); +})->skip(! withoutIntl(), 'ext-intl is installed.');