Skip to content
Open
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
1.5.2 (unreleased)
=====

* (improvement) Reduce normalization overhead by tracking the debug stack internally across recursion instead of mutating it in context on each nested value.
* (improvement) Cache Doctrine-normalized class names in `SimpleNormalizer` to avoid repeated metadata lookups for the same object type.
* (improvement) Add test coverage to ensure class-name normalization is cached across repeated normalization calls.
* (improvement) Optimize `ValidJsonVerifier` by reusing a mutable path stack during traversal instead of allocating a new path array for each nested element.
* (improvement) Add dedicated verifier tests for deep-path reporting and first-invalid-element detection.
* (improvement) Optimize empty `stdClass` detection by using an `(array)` cast check instead of `get_object_vars()`.
* (improvement) Add a default max-depth guard (128) for normalization and JSON verification to mitigate deep-nesting DoS risk.


1.5.1
=====

Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"phpunit/phpunit": "^13.0.5",
"roave/security-advisories": "dev-latest"
},
"suggest": {
"doctrine/orm": "Can be used to normalize Doctrine entity class names (for example proxy classes to their mapped entity class)."
},
"autoload": {
"psr-4": {
"Torr\\SimpleNormalizer\\": "src/"
Expand Down
7 changes: 7 additions & 0 deletions src/Exception/InvalidMaxDepthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Exception;

final class InvalidMaxDepthException extends \InvalidArgumentException implements NormalizerExceptionInterface
{
}
2 changes: 1 addition & 1 deletion src/Exception/NormalizationFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Torr\SimpleNormalizer\Exception;

/**
* Generic failure exception, for usage inside of custom normalizers.
* Generic failure exception, for usage inside custom normalizers.
*/
class NormalizationFailedException extends \RuntimeException implements NormalizerExceptionInterface
{
Expand Down
123 changes: 76 additions & 47 deletions src/Normalizer/SimpleNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Doctrine\ORM\Mapping\ClassMetadataFactory;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Torr\SimpleNormalizer\Exception\InvalidMaxDepthException;
use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException;
use Torr\SimpleNormalizer\Exception\UnsupportedTypeException;
use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier;
Expand All @@ -18,14 +19,15 @@
* The verifier is done on the top-level of every method (instead of at the point where the invalid values could occur
* = the object normalizers), as this way we can provide a full path to the invalid element in the JSON.
*
* @readonly
*
* @final
*/
class SimpleNormalizer
{
private readonly ?ClassMetadataFactory $doctrineMetadata;
private const string STACK_CONTEXT = "simple-normalizer.debug-stack";
private const int DEFAULT_MAX_DEPTH = 128;

/** @var array<class-string, class-string> */
private array $normalizedClassNames = [];

/**
* @param ServiceLocator<SimpleObjectNormalizerInterface> $objectNormalizers
Expand All @@ -35,20 +37,27 @@ public function __construct (
private readonly bool $isDebug = false,
private readonly ?ValidJsonVerifier $validJsonVerifier = null,
?EntityManagerInterface $entityManager = null,
private readonly int $maxDepth = self::DEFAULT_MAX_DEPTH,
)
{
if ($this->maxDepth < 1)
{
throw new InvalidMaxDepthException("The max depth must be at least 1.");
}

$this->doctrineMetadata = $entityManager?->getMetadataFactory();
}

/**
*/
public function normalize (mixed $value, array $context = []) : mixed
{
$normalizedValue = $this->recursiveNormalize($value, $context);
$stack = [];
$normalizedValue = $this->recursiveNormalize($value, $context, $stack);

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -58,11 +67,12 @@ public function normalize (mixed $value, array $context = []) : mixed
*/
public function normalizeArray (array $array, array $context = []) : array
{
$normalizedValue = $this->recursiveNormalizeArray($array, $context);
$stack = [];
$normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack);

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -75,11 +85,12 @@ public function normalizeArray (array $array, array $context = []) : array
public function normalizeMap (array $array, array $context = []) : array|\stdClass
{
// return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON.
$normalizedValue = $this->recursiveNormalizeArray($array, $context) ?: new \stdClass();
$stack = [];
$normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack) ?: new \stdClass();

if ($this->isDebug)
{
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue);
$this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth);
}

return $normalizedValue;
Expand All @@ -89,58 +100,71 @@ public function normalizeMap (array $array, array $context = []) : array|\stdCla
* The actual normalize logic, that recursively normalizes the value.
* It must never call one of the public methods above and just normalizes the value.
*/
private function recursiveNormalize (mixed $value, array $context = []) : mixed
private function recursiveNormalize (mixed $value, array $context, array &$stack) : mixed
{
if (null === $value || \is_scalar($value))
{
return $value;
}

if (!isset($context[self::STACK_CONTEXT]) || !\is_array($context[self::STACK_CONTEXT]))
if (\count($stack) >= $this->maxDepth)
{
$context[self::STACK_CONTEXT] = [];
$extendedStack = [...$stack, get_debug_type($value)];

throw new UnsupportedTypeException(\sprintf(
"Maximum normalization depth of %d exceeded when normalizing type %s in stack %s",
$this->maxDepth,
get_debug_type($value),
implode(" > ", array_reverse($extendedStack)),
));
}

$context[self::STACK_CONTEXT][] = get_debug_type($value);
$stack[] = get_debug_type($value);

if (\is_array($value))
try
{
return $this->recursiveNormalizeArray($value, $context);
}

if (\is_object($value))
{
// Allow empty stdClass as a way to force a JSON {} instead of an
// array which would encode to []
if ($value instanceof \stdClass && [] === get_object_vars($value))
if (\is_array($value))
{
return $value;
return $this->recursiveNormalizeArray($value, $context, $stack);
}

try
if (\is_object($value))
{
$className = $this->normalizeClassName($value::class);

$normalizer = $this->objectNormalizers->get($className);
\assert($normalizer instanceof SimpleObjectNormalizerInterface);

return $normalizer->normalize($value, $context, $this);
// Allow empty stdClass as a way to force a JSON {} instead of an
// array which would encode to []
if ($value instanceof \stdClass && [] === (array) $value)
{
return $value;
}

try
{
$className = $this->normalizeClassName($value::class);
$normalizer = $this->objectNormalizers->get($className);
\assert($normalizer instanceof SimpleObjectNormalizerInterface);

return $normalizer->normalize($value, $context, $this);
}
catch (ServiceNotFoundException $exception)
{
throw new ObjectTypeNotSupportedException(\sprintf(
"Can't normalize type '%s' in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($stack)),
), 0, $exception);
}
}
catch (ServiceNotFoundException $exception)
{
throw new ObjectTypeNotSupportedException(\sprintf(
"Can't normalize type '%s' in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($context[self::STACK_CONTEXT])),
), 0, $exception);
}
}

throw new UnsupportedTypeException(\sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($context[self::STACK_CONTEXT])),
));
throw new UnsupportedTypeException(\sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($stack)),
));
}
finally
{
array_pop($stack);
}
}

/**
Expand All @@ -158,7 +182,12 @@ private function normalizeClassName (string $className) : string
return $className;
}

return $this->doctrineMetadata->hasMetadataFor($className)
if (isset($this->normalizedClassNames[$className]))
{
return $this->normalizedClassNames[$className];
}

return $this->normalizedClassNames[$className] = $this->doctrineMetadata->hasMetadataFor($className)
? $this->doctrineMetadata->getMetadataFor($className)->getName()
: $className;
}
Expand All @@ -167,14 +196,14 @@ private function normalizeClassName (string $className) : string
* The actual customized normalization logic for arrays, that recursively normalizes the value.
* It must never call one of the public methods above and just normalizes the value.
*/
private function recursiveNormalizeArray (array $array, array $context = []) : array
private function recursiveNormalizeArray (array $array, array $context, array &$stack) : array
{
$result = [];
$isList = array_is_list($array);

foreach ($array as $key => $value)
{
$normalized = $this->recursiveNormalize($value, $context);
$normalized = $this->recursiveNormalize($value, $context, $stack);

// if the array was a list and the normalized value is null, just filter it out
if ($isList && null === $normalized)
Expand Down
35 changes: 24 additions & 11 deletions src/Normalizer/Validator/ValidJsonVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
class ValidJsonVerifier
{
/**
* Ensures that only valid JSON types are present in the value, that means scalars, arrays and empty objects.
* Ensures that only valid JSON types are present in the value that means scalars, arrays, and empty objects.
*
* @param positive-int $maxDepth
*/
public function ensureValidOnlyJsonTypes (mixed $value) : void
public function ensureValidOnlyJsonTypes (mixed $value, int $maxDepth) : void
{
$invalidElement = $this->findInvalidJsonElement($value);
$path = ["$"];
$invalidElement = $this->findInvalidJsonElement($value, $path, $maxDepth);

if (null !== $invalidElement)
{
Expand All @@ -34,10 +37,21 @@ public function ensureValidOnlyJsonTypes (mixed $value) : void
* Searches through the value and looks for anything that isn't valid JSON
* (scalars, arrays or empty objects).
*
* @param positive-int $maxDepth
*
* @return InvalidJsonElement|null returns null if everything is valid, otherwise the invalid value
*/
private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?InvalidJsonElement
private function findInvalidJsonElement (mixed $value, array &$path, int $maxDepth) : ?InvalidJsonElement
{
if (\count($path) - 1 > $maxDepth)
{
throw new IncompleteNormalizationException(\sprintf(
"Maximum JSON verification depth of %d exceeded at path '%s'.",
$maxDepth,
implode(".", $path),
));
}

// scalars are always valid
if (null === $value || \is_scalar($value))
{
Expand All @@ -47,19 +61,18 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I
// only empty stdClass objects are allowed (as they are used to serialize to `{}`)
if (\is_object($value))
{
return $value instanceof \stdClass && [] === get_object_vars($value)
return $value instanceof \stdClass && [] === (array) $value
? null
: new InvalidJsonElement($value, $path);
: new InvalidJsonElement($value, [...$path]);
}

if (\is_array($value))
{
foreach ($value as $key => $item)
{
$invalidItem = $this->findInvalidJsonElement(
$item,
[...$path, $key],
);
$path[] = $key;
$invalidItem = $this->findInvalidJsonElement($item, $path, $maxDepth);
array_pop($path);

if (null !== $invalidItem)
{
Expand All @@ -70,6 +83,6 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I
return null;
}

return new InvalidJsonElement($value, $path);
return new InvalidJsonElement($value, [...$path]);
}
}
Loading
Loading