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..98f1b26 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/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 @@
-
-
+
+
+
+
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.
*