From 022add4a0c75d445c10c5f327f75dce981f4fae3 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Fri, 4 Sep 2026 18:31:41 +0200 Subject: [PATCH 1/3] feat: Implement DiagnosticValueFormatter for safe representation of untrusted values and update RangeException to utilize it. Close #3 --- README.md | 35 ++- composer.json | 3 +- .../Diagnostic/DiagnosticValueFormatter.php | 169 ++++++++++++ .../Core/Exception/RangeException.php | 27 +- .../DiagnosticValueFormatterContractTest.php | 248 ++++++++++++++++++ .../DiagnosticValueFormatterTest.php | 94 +++++++ .../Unit/Exception/RangeExceptionTest.php | 36 +-- 7 files changed, 567 insertions(+), 45 deletions(-) create mode 100644 lib/LongitudeOne/Core/Diagnostic/DiagnosticValueFormatter.php create mode 100644 tests/LongitudeOne/Core/Tests/Contract/Diagnostic/DiagnosticValueFormatterContractTest.php create mode 100644 tests/LongitudeOne/Core/Tests/Unit/Diagnostic/DiagnosticValueFormatterTest.php diff --git a/README.md b/README.md index 0110d2b..196c945 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ composer require longitude-one/spatial-core:1.0.0 ## Support policy This package supports PHP 8.3 and later. Its public API includes the enums documented below, -`ExceptionInterface`, and `RangeException`. +`DiagnosticValueFormatter`, `ExceptionInterface`, and `RangeException`. ## Geographic axes @@ -192,6 +192,39 @@ assert($model->isGeography()); assert($model->requiresGeographicCoordinateRanges()); ``` +## Diagnostic value formatter + +`DiagnosticValueFormatter` produces a safe, single-line and bounded representation of an untrusted +value before it is placed in an exception message, log entry, or terminal output. It makes control and +problematic invisible Unicode characters visible, neutralizes terminal escape sequences, preserves valid +UTF-8 where possible, and limits the result to 2,048 characters. It does not validate spatial data or +escape data for HTML, JSON, XML, or SQL. + +```php +use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter; + +$value = "POINT(2.3522 48.8566)\n"; + +assert('POINT(2.3522 48.8566)\\n' === DiagnosticValueFormatter::format($value)); +``` + +Exception factories can use the formatter while retaining their package-specific exception hierarchy: + +```php +use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter; + +final class InvalidValueException extends \InvalidArgumentException implements ExceptionInterface +{ + public static function invalidCoordinate(string $coordinate): self + { + return new self(sprintf( + 'Invalid coordinate "%s".', + DiagnosticValueFormatter::format($coordinate), + )); + } +} +``` + ## Development Run the test suite: diff --git a/composer.json b/composer.json index a963340..26f9733 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ ], "license": "MIT", "require": { - "php": "^8.3" + "php": "^8.3", + "ext/mbstring": "*" }, "require-dev": { "phpunit/phpunit": "^12.5.34", diff --git a/lib/LongitudeOne/Core/Diagnostic/DiagnosticValueFormatter.php b/lib/LongitudeOne/Core/Diagnostic/DiagnosticValueFormatter.php new file mode 100644 index 0000000..89259fd --- /dev/null +++ b/lib/LongitudeOne/Core/Diagnostic/DiagnosticValueFormatter.php @@ -0,0 +1,169 @@ + '\0', + "\t" => '\t', + "\n" => '\n', + "\r" => '\r', + default => 1 === preg_match('/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u', $character) + ? sprintf('\u{%04X}', mb_ord($character)) + : $character, + }; + } + + /** + * Formats a string that contains malformed UTF-8 while retaining its valid characters. + * + * @param string $value malformed UTF-8 value + */ + private static function formatInvalidUtf8(string $value): string + { + $formatted = ''; + $formattedLength = 0; + $offset = 0; + $valueLength = mb_strlen($value, '8bit'); + + while ($offset < $valueLength) { + $byte = ord($value[$offset]); + $characterLength = self::utf8CharacterLength($byte); + $character = mb_substr($value, $offset, $characterLength, '8bit'); + + if ($characterLength === mb_strlen($character, '8bit') && 1 === preg_match('//u', $character)) { + $replacement = self::formatCharacter($character); + $offset += $characterLength; + } else { + $replacement = sprintf('\x%02X', $byte); + ++$offset; + } + + if (!self::appendReplacement($formatted, $formattedLength, $replacement)) { + return self::truncate($formatted); + } + } + + return $formatted; + } + + /** + * Formats a valid UTF-8 value without expanding the full input into an intermediate array. + * + * @param string $value valid UTF-8 value + */ + private static function formatValidUtf8(string $value): string + { + $formatted = ''; + $formattedLength = 0; + $offset = 0; + $valueLength = mb_strlen($value, '8bit'); + + while ($offset < $valueLength) { + $characterLength = self::utf8CharacterLength(ord($value[$offset])); + $character = mb_substr($value, $offset, $characterLength, '8bit'); + + if (!self::appendReplacement($formatted, $formattedLength, self::formatCharacter($character))) { + return self::truncate($formatted); + } + + $offset += $characterLength; + } + + return $formatted; + } + + /** + * Truncates a diagnostic representation without exceeding the maximum length. + * + * @param string $value formatted value to truncate + */ + private static function truncate(string $value): string + { + return mb_substr($value, 0, self::MAX_LENGTH - mb_strlen(self::TRUNCATION_SUFFIX)).self::TRUNCATION_SUFFIX; + } + + /** + * Returns the expected byte length for a UTF-8 character starting with a byte. + * + * @param int $byte first byte of a potential UTF-8 character + */ + private static function utf8CharacterLength(int $byte): int + { + return match (true) { + $byte <= 0x7F => 1, + $byte >= 0xC2 && $byte <= 0xDF => 2, + $byte >= 0xE0 && $byte <= 0xEF => 3, + $byte >= 0xF0 && $byte <= 0xF4 => 4, + default => 1, + }; + } +} diff --git a/lib/LongitudeOne/Core/Exception/RangeException.php b/lib/LongitudeOne/Core/Exception/RangeException.php index ac3ff38..810044b 100644 --- a/lib/LongitudeOne/Core/Exception/RangeException.php +++ b/lib/LongitudeOne/Core/Exception/RangeException.php @@ -22,6 +22,7 @@ namespace LongitudeOne\Core\Exception; +use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter; use LongitudeOne\Core\Enum\AxisEnum; /** @@ -39,11 +40,6 @@ class RangeException extends \RangeException implements ExceptionInterface public const SECONDS_MESSAGE = 'Seconds must be between 0 and 59'; public const SECONDS_OUT_OF_RANGE = 60; - /** - * Maximum length of the value embedded in the exception message. - */ - private const MAX_VALUE_LENGTH = 100; - /** * Creates a range exception for the supplied value and error code. * @@ -53,7 +49,7 @@ class RangeException extends \RangeException implements ExceptionInterface */ public function __construct(string $value, int $code, ?\Throwable $previous = null) { - $message = sprintf('[RangeException] %s, got "%s".', $this->setMessage($code), $this->sanitizeValue($value)); + $message = sprintf('[RangeException] %s, got "%s".', $this->setMessage($code), DiagnosticValueFormatter::format($value)); parent::__construct($message, $code, $previous); } @@ -77,25 +73,6 @@ public static function forAxis(AxisEnum $axis, string $value, ?\Throwable $previ ); } - /** - * Strip control characters (CR, LF, ...) and truncate a value before it is embedded in the exception message. - * - * Prevents a large or crafted input from inflating the exception message size or forging fake log lines - * when that message is logged as-is. - * - * @param string $value value to sanitize - */ - private function sanitizeValue(string $value): string - { - $value = (string) preg_replace('/[\x00-\x1F\x7F]/', ' ', $value); - - if (mb_strlen($value) > self::MAX_VALUE_LENGTH) { - return mb_substr($value, 0, self::MAX_VALUE_LENGTH).'...'; - } - - return $value; - } - /** * Returns the range error message associated with an error code. * diff --git a/tests/LongitudeOne/Core/Tests/Contract/Diagnostic/DiagnosticValueFormatterContractTest.php b/tests/LongitudeOne/Core/Tests/Contract/Diagnostic/DiagnosticValueFormatterContractTest.php new file mode 100644 index 0000000..6b24014 --- /dev/null +++ b/tests/LongitudeOne/Core/Tests/Contract/Diagnostic/DiagnosticValueFormatterContractTest.php @@ -0,0 +1,248 @@ +assertDiagnosticInvariants($result); + } + } + + /** + * No ASCII control character, including DEL, is emitted unchanged. + */ + public function testAsciiControlCharactersAreNeutralized(): void + { + $value = implode('', array_map(chr(...), range(0x00, 0x1F))).chr(0x7F); + $result = DiagnosticValueFormatter::format($value); + + foreach (range(0x00, 0x1F) as $controlCharacter) { + self::assertStringNotContainsString(chr($controlCharacter), $result); + } + + self::assertStringNotContainsString(chr(0x7F), $result); + self::assertStringContainsString('\n', $result); + self::assertStringContainsString('\r', $result); + self::assertStringContainsString('\t', $result); + self::assertStringContainsString('\0', $result); + $this->assertDiagnosticInvariants($result); + } + + /** + * Empty, control-only, whitespace-only, and separator-free inputs remain safe. + */ + public function testBoundaryInputsAreRobust(): void + { + $values = [ + 'empty' => '', + 'control only' => "\0\t\n\r\x1B", + 'spaces only' => ' ', + 'very long without separators' => str_repeat('x', self::MAX_EXPECTED_LENGTH * 100), + ]; + + foreach ($values as $name => $value) { + $result = DiagnosticValueFormatter::format($value); + + if ('empty' === $name) { + self::assertSame('', $result); + } else { + self::assertNotSame('', $result, $name); + } + + $this->assertDiagnosticInvariants($result); + } + } + + /** + * Formatting an already formatted value cannot change it further. + */ + public function testFormattingIsIdempotent(): void + { + $value = "POINT(1 2)\n\x1B[31m\u{202E}\xC3"; + $result = DiagnosticValueFormatter::format($value); + $formattedAgain = DiagnosticValueFormatter::format($result); + + self::assertSame($result, $formattedAgain); + $this->assertDiagnosticInvariants($result); + $this->assertDiagnosticInvariants($formattedAgain); + } + + /** + * Length, controls, ANSI, and invisible Unicode protections compose without weakening one another. + */ + public function testHostileCombinedInputRespectsEveryProtection(): void + { + $value = str_repeat("POINT(1 2)\n\x1B[2J\u{202E}", self::MAX_EXPECTED_LENGTH); + $result = DiagnosticValueFormatter::format($value); + + self::assertStringContainsString('POINT(1 2)\n\u{001B}[2J\u{202E}', $result); + self::assertStringNotContainsString("\x1B", $result); + self::assertStringEndsWith('…', $result); + $this->assertDiagnosticInvariants($result); + } + + /** + * Values at and around the limit preserve their content or receive a bounded truncation suffix. + */ + public function testLengthContract(): void + { + $values = [ + 'empty' => '', + 'one character' => 'a', + 'one below the limit' => str_repeat('a', self::MAX_EXPECTED_LENGTH - 1), + 'at the limit' => str_repeat('a', self::MAX_EXPECTED_LENGTH), + 'one above the limit' => str_repeat('a', self::MAX_EXPECTED_LENGTH + 1), + 'very large value' => str_repeat('a', self::MAX_EXPECTED_LENGTH * 100), + ]; + + foreach ($values as $name => $value) { + $result = DiagnosticValueFormatter::format($value); + + if (mb_strlen($value) <= self::MAX_EXPECTED_LENGTH) { + self::assertSame($value, $result, $name); + } else { + self::assertStringEndsWith('…', $result, $name); + } + + $this->assertDiagnosticInvariants($result); + } + } + + /** + * Truncation neither splits a multibyte character nor invalidates UTF-8. + */ + public function testMultibyteUtf8TruncationKeepsCharacterBoundaries(): void + { + $justBeforeTheLimit = str_repeat('a', self::MAX_EXPECTED_LENGTH - 1).'é'; + $justAfterTheLimit = str_repeat('a', self::MAX_EXPECTED_LENGTH - 2).'東xy京z'; + + $preservedResult = DiagnosticValueFormatter::format($justBeforeTheLimit); + $truncatedResult = DiagnosticValueFormatter::format($justAfterTheLimit); + + self::assertSame($justBeforeTheLimit, $preservedResult); + self::assertSame(str_repeat('a', self::MAX_EXPECTED_LENGTH - 2).'東…', $truncatedResult); + $this->assertDiagnosticInvariants($preservedResult); + $this->assertDiagnosticInvariants($truncatedResult); + } + + /** + * Multiple input lines become one safe diagnostic line. + */ + public function testMultilineInputProducesSingleLine(): void + { + $result = DiagnosticValueFormatter::format("first\nsecond\rthird\r\nfourth"); + + self::assertSame('first\nsecond\rthird\r\nfourth', $result); + $this->assertDiagnosticInvariants($result); + } + + /** + * Ordinary ASCII, WKT, and common Unicode remain useful and unchanged. + */ + public function testNormalInputsArePreserved(): void + { + $values = [ + 'ASCII' => 'ordinary diagnostic value', + 'WKT' => 'POINT(2.3522 48.8566)', + 'common Unicode' => 'é 東京 🗺️', + ]; + + foreach ($values as $value) { + $result = DiagnosticValueFormatter::format($value); + + self::assertSame($value, $result); + $this->assertDiagnosticInvariants($result); + } + } + + /** + * Characters belonging to HTML, JSON, XML, and SQL escaping remain outside this formatter's scope. + */ + public function testOutOfScopeEscapingCharactersArePreserved(): void + { + $value = "<>\"'&{}\\"; + $result = DiagnosticValueFormatter::format($value); + + self::assertSame($value, $result); + $this->assertDiagnosticInvariants($result); + } + + /** + * Bidirectional and zero-width characters have an explicit, non-misleading representation. + */ + public function testProblematicUnicodeCharactersAreExplicitlyRendered(): void + { + $value = "a\u{202E}b\u{202A}c\u{2066}d\u{2069}e\u{200B}f\u{FEFF}g"; + $result = DiagnosticValueFormatter::format($value); + + self::assertSame( + 'a\u{202E}b\u{202A}c\u{2066}d\u{2069}e\u{200B}f\u{FEFF}g', + $result, + ); + $this->assertDiagnosticInvariants($result); + } + + /** + * A truncated WKT value retains the beginning that identifies it to a developer. + */ + public function testTruncatedWktPreservesUsefulDiagnosticContext(): void + { + $value = 'POLYGON(('.str_repeat('2.3522 48.8566, ', self::MAX_EXPECTED_LENGTH).'))'; + $result = DiagnosticValueFormatter::format($value); + + self::assertStringStartsWith('POLYGON((2.3522 48.8566, ', $result); + self::assertStringEndsWith('…', $result); + $this->assertDiagnosticInvariants($result); + } + + /** + * Checks the invariants every formatted diagnostic value must respect. + * + * @param string $result formatted diagnostic value + */ + private function assertDiagnosticInvariants(string $result): void + { + self::assertStringNotContainsString("\n", $result); + self::assertStringNotContainsString("\r", $result); + self::assertTrue(mb_check_encoding($result, 'UTF-8')); + self::assertLessThanOrEqual(self::MAX_EXPECTED_LENGTH, mb_strlen($result)); + } +} diff --git a/tests/LongitudeOne/Core/Tests/Unit/Diagnostic/DiagnosticValueFormatterTest.php b/tests/LongitudeOne/Core/Tests/Unit/Diagnostic/DiagnosticValueFormatterTest.php new file mode 100644 index 0000000..d5c8e91 --- /dev/null +++ b/tests/LongitudeOne/Core/Tests/Unit/Diagnostic/DiagnosticValueFormatterTest.php @@ -0,0 +1,94 @@ + + */ + public static function oversizedValues(): iterable + { + yield 'plain characters' => [str_repeat('a', DiagnosticValueFormatter::MAX_LENGTH + 1)]; + + yield 'expanding newlines' => [str_repeat("\n", DiagnosticValueFormatter::MAX_LENGTH)]; + } +} diff --git a/tests/LongitudeOne/Core/Tests/Unit/Exception/RangeExceptionTest.php b/tests/LongitudeOne/Core/Tests/Unit/Exception/RangeExceptionTest.php index 0ed3e88..452ff83 100644 --- a/tests/LongitudeOne/Core/Tests/Unit/Exception/RangeExceptionTest.php +++ b/tests/LongitudeOne/Core/Tests/Unit/Exception/RangeExceptionTest.php @@ -25,6 +25,24 @@ */ final class RangeExceptionTest extends TestCase { + /** + * A crafted value is delegated to the shared diagnostic formatter before it reaches the message. + */ + public function testDiagnosticValueIsSanitizedBeforeItIsAddedToTheMessage(): void + { + $value = "invalid\nvalue\x1B[31m"; + $exception = new RangeException($value, RangeException::LATITUDE_OUT_OF_RANGE); + + $sanitizedValue = 'invalid\nvalue\u{001B}[31m'; + + self::assertSame( + sprintf('[RangeException] %s, got "%s".', RangeException::LATITUDE_MESSAGE, $sanitizedValue), + $exception->getMessage(), + ); + self::assertStringNotContainsString("\n", $exception->getMessage()); + self::assertStringNotContainsString("\x1B", $exception->getMessage()); + } + /** * Tests that latitude values use the latitude range error. */ @@ -49,24 +67,6 @@ public function testLongitudeUsesTheLongitudeRangeError(): void self::assertSame('[RangeException] Longitude must be between -180 and 180, got "181".', $exception->getMessage()); } - /** - * A long crafted value cannot forge a new log line or inflate the exception message. - */ - public function testLongValueIsSanitizedBeforeItIsAddedToTheMessage(): void - { - $value = str_repeat('a', 99)."\n".str_repeat('b', 10); - $exception = new RangeException($value, RangeException::LATITUDE_OUT_OF_RANGE); - - $sanitizedValue = str_repeat('a', 99).' ...'; - - self::assertSame( - sprintf('[RangeException] %s, got "%s".', RangeException::LATITUDE_MESSAGE, $sanitizedValue), - $exception->getMessage(), - ); - self::assertStringNotContainsString("\n", $exception->getMessage()); - self::assertStringNotContainsString("\r", $exception->getMessage()); - } - /** * Every range error code produces its corresponding message. * From e5d92fecd1c92a5f6b6cd3d2f7c3687731f44c40 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Fri, 4 Sep 2026 18:42:05 +0200 Subject: [PATCH 2/3] fix: Corrected the extension name for mbstring in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 26f9733..98f1b26 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "license": "MIT", "require": { "php": "^8.3", - "ext/mbstring": "*" + "ext-mbstring": "*" }, "require-dev": { "phpunit/phpunit": "^12.5.34", From d1c78eb0bbd12b8669421c055452008b54dddad3 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Fri, 4 Sep 2026 18:46:44 +0200 Subject: [PATCH 3/3] quality: Update code-size rule set to exclude public-method limit for test classes --- quality/php-mess-detector/test-ruleset.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quality/php-mess-detector/test-ruleset.xml b/quality/php-mess-detector/test-ruleset.xml index 2cd7fdd..a1d43ea 100644 --- a/quality/php-mess-detector/test-ruleset.xml +++ b/quality/php-mess-detector/test-ruleset.xml @@ -9,8 +9,10 @@ - - + + + +