Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
],
"license": "MIT",
"require": {
"php": "^8.3"
"php": "^8.3",
"ext-mbstring": "*"
},
"require-dev": {
"phpunit/phpunit": "^12.5.34",
Expand Down
169 changes: 169 additions & 0 deletions lib/LongitudeOne/Core/Diagnostic/DiagnosticValueFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php
/**
* This file is part of the LongitudeOne Spatial core library.
*
* PHP 8.3 | 8.4 | 8.5
*
* Copyright LongitudeOne - Alexandre Tranchant.
* Copyright 2026.
*
*/

declare(strict_types=1);

namespace LongitudeOne\Core\Diagnostic;

/**
* Produces safe, single-line representations of untrusted diagnostic values.
*/
final class DiagnosticValueFormatter
{
/**
* Maximum number of characters in a formatted value, including the truncation suffix.
*/
public const MAX_LENGTH = 2048;

private const TRUNCATION_SUFFIX = '…';

/**
* Formats a value for inclusion in a diagnostic message.
*
* Control, format, and Unicode line-separator characters are rendered as visible escape sequences.
* Invalid UTF-8 bytes are escaped individually, so the returned value is always valid UTF-8.
*
* @param string $value untrusted value to include in a diagnostic message
*/
public static function format(string $value): string
{
// The empty UTF-8 pattern validates the entire input before it is read character by character.
if (1 === preg_match('//u', $value)) {
return self::formatValidUtf8($value);
}

return self::formatInvalidUtf8($value);
}

/**
* Appends a replacement when it fits within the diagnostic output limit.
*
* @param string $formatted formatted value under construction
* @param int $formattedLength character count of the formatted value
* @param string $replacement safe representation to append
*/
private static function appendReplacement(string &$formatted, int &$formattedLength, string $replacement): bool
{
$replacementLength = mb_strlen($replacement);

if (self::MAX_LENGTH < $formattedLength + $replacementLength) {
return false;
}

$formatted .= $replacement;
$formattedLength += $replacementLength;

return true;
}

/**
* Returns a safe rendering for one valid UTF-8 character.
*
* @param string $character valid UTF-8 character
*/
private static function formatCharacter(string $character): string
{
return match ($character) {
"\0" => '\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,
};
}
}
27 changes: 2 additions & 25 deletions lib/LongitudeOne/Core/Exception/RangeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

namespace LongitudeOne\Core\Exception;

use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter;
use LongitudeOne\Core\Enum\AxisEnum;

/**
Expand All @@ -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.
*
Expand All @@ -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);
}
Expand All @@ -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.
*
Expand Down
6 changes: 4 additions & 2 deletions quality/php-mess-detector/test-ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
</description>
<!-- Import the entire unused code rule set, but UnusedPrivateMethod -->
<rule ref="rulesets/unusedcode.xml" />
<!-- Import the entire code size rule, but excessive method length -->
<rule ref="rulesets/codesize.xml"/>
<!-- Import the entire code-size rule set, except the public-method limit for test classes -->
<rule ref="rulesets/codesize.xml">
<exclude name="TooManyPublicMethods"/>
</rule>
<!-- Import the entire naming rule set -->
<rule ref="rulesets/naming.xml" />
<!-- Import the entire controversial rule set -->
Expand Down
Loading