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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* (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.
* (improvement) Add stack trace to all relevant exceptions as getter.
* (improvement) Add stack trace to NormalizationFailedExceptions thrown in custom object normalizers.


1.5.1
Expand Down
33 changes: 33 additions & 0 deletions src/Exception/AbstractNormalizerException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types=1);

namespace Torr\SimpleNormalizer\Exception;

abstract class AbstractNormalizerException extends \RuntimeException implements NormalizerExceptionInterface
{
/** @var list<string> */
private array $normalizationStack;

/**
* @param list<string> $normalizationStack
*/
public function __construct (
string $message = "",
int $code = 0,
?\Throwable $previous = null,
array $normalizationStack = [],
)
{
parent::__construct($message, $code, $previous);
$this->normalizationStack = $normalizationStack;
}

/**
* Returns the stack to the normalization issue.
*
* @return list<string>
*/
public function getNormalizationStack () : array
{
return $this->normalizationStack;
}
}
2 changes: 1 addition & 1 deletion src/Exception/Context/InvalidContextTypeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static function create (
mixed $value,
string $expected,
?\Throwable $previous = null,
) : static
) : self
{
return new self(
\sprintf(
Expand Down
2 changes: 1 addition & 1 deletion src/Exception/Context/MissingContextException.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static function create (
string $missingKey,
array $allKeys,
?\Throwable $previous = null,
) : static
) : self
{
return new self(
\sprintf(
Expand Down
2 changes: 1 addition & 1 deletion src/Exception/NormalizationFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
/**
* Generic failure exception, for usage inside custom normalizers.
*/
class NormalizationFailedException extends \RuntimeException implements NormalizerExceptionInterface
class NormalizationFailedException extends AbstractNormalizerException
{
}
2 changes: 1 addition & 1 deletion src/Exception/ObjectTypeNotSupportedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

namespace Torr\SimpleNormalizer\Exception;

final class ObjectTypeNotSupportedException extends \RuntimeException implements NormalizerExceptionInterface
final class ObjectTypeNotSupportedException extends AbstractNormalizerException
{
}
2 changes: 1 addition & 1 deletion src/Exception/UnsupportedTypeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

namespace Torr\SimpleNormalizer\Exception;

final class UnsupportedTypeException extends \RuntimeException implements NormalizerExceptionInterface
final class UnsupportedTypeException extends AbstractNormalizerException
{
}
83 changes: 63 additions & 20 deletions src/Normalizer/SimpleNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ 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.
$stack = [];

// return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON.
$normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack) ?: new \stdClass();

if ($this->isDebug)
Expand All @@ -100,8 +101,10 @@ public function normalizeMap (array $array, array $context = []) : array|\stdCla
}

/**
* The actual normalize logic, that recursively normalizes the value.
* The actual normalization logic that recursively normalizes the value.
* It must never call one of the public methods above and just normalizes the value.
*
* @param list<string> $stack
*/
private function recursiveNormalize (mixed $value, array $context, array &$stack) : mixed
{
Expand All @@ -113,13 +116,17 @@ private function recursiveNormalize (mixed $value, array $context, array &$stack
if (\count($stack) >= $this->maxDepth)
{
$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)),
));
$normalizationStack = self::prepareStack($extendedStack);

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

$stack[] = get_debug_type($value);
Expand Down Expand Up @@ -150,30 +157,53 @@ private function recursiveNormalize (mixed $value, array $context, array &$stack
}
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);
$normalizationStack = self::prepareStack($stack);

throw new ObjectTypeNotSupportedException(
message: \sprintf(
"Can't normalize type '%s' in stack %s",
get_debug_type($value),
implode(" > ", $normalizationStack),
),
previous: $exception,
normalizationStack: $normalizationStack,
);
}
catch (MissingContextException|InvalidContextTypeException $exception)
{
$normalizationStack = self::prepareStack($stack);

throw new NormalizationFailedException(
message: \sprintf(
"Normalization failed: %s at %s",
$exception->getMessage(),
implode(" > ", array_reverse($stack)),
implode(" > ", $normalizationStack),
),
previous: $exception,
normalizationStack: $normalizationStack,
);
}
catch (NormalizationFailedException $exception)
{
// rewrap to add normalization stack
throw new NormalizationFailedException(
message: $exception->getMessage(),
previous: $exception,
normalizationStack: self::prepareStack($stack),
);
}
}

throw new UnsupportedTypeException(\sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", array_reverse($stack)),
));
$normalizationStack = self::prepareStack($stack);

throw new UnsupportedTypeException(
message: \sprintf(
"Can't normalize type %s in stack %s",
get_debug_type($value),
implode(" > ", $normalizationStack),
),
normalizationStack: $normalizationStack,
);
}
finally
{
Expand Down Expand Up @@ -209,6 +239,8 @@ 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.
*
* @param list<string> $stack
*/
private function recursiveNormalizeArray (array $array, array $context, array &$stack) : array
{
Expand Down Expand Up @@ -239,4 +271,15 @@ private function recursiveNormalizeArray (array $array, array $context, array &$

return $result;
}

/**
* @return list<string>
*/
private static function prepareStack (array $rawStack) : array
{
$stack = array_reverse($rawStack);
\assert(array_is_list($stack));

return $stack;
}
}
24 changes: 24 additions & 0 deletions tests/Exception/NormalizationFailedExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types=1);

namespace Tests\Torr\SimpleNormalizer\Exception;

use PHPUnit\Framework\TestCase;
use Torr\SimpleNormalizer\Exception\NormalizationFailedException;

/**
* @internal
*/
final class NormalizationFailedExceptionTest extends TestCase
{
/**
*/
public function testStoresNormalizationStack () : void
{
$exception = new NormalizationFailedException(
message: "failed",
normalizationStack: ["Root", "Nested", "Leaf"],
);

self::assertSame(["Root", "Nested", "Leaf"], $exception->getNormalizationStack());
}
}
24 changes: 24 additions & 0 deletions tests/Exception/UnsupportedTypeExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types=1);

namespace Tests\Torr\SimpleNormalizer\Exception;

use PHPUnit\Framework\TestCase;
use Torr\SimpleNormalizer\Exception\UnsupportedTypeException;

/**
* @internal
*/
final class UnsupportedTypeExceptionTest extends TestCase
{
/**
*/
public function testStoresNormalizationStack () : void
{
$exception = new UnsupportedTypeException(
message: "failed",
normalizationStack: ["Root", "Nested", "Leaf"],
);

self::assertSame(["Root", "Nested", "Leaf"], $exception->getNormalizationStack());
}
}
65 changes: 65 additions & 0 deletions tests/Normalizer/ObjectNormalizationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Tests\Torr\SimpleNormalizer\Fixture\DummyVO;
use Torr\SimpleNormalizer\Context\ContextBag;
use Torr\SimpleNormalizer\Exception\NormalizationFailedException;
use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException;
use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer;
use Torr\SimpleNormalizer\Normalizer\SimpleObjectNormalizerInterface;
Expand Down Expand Up @@ -125,4 +127,67 @@ public function testMissingNormalizerKeepsPreviousException () : void
self::assertSame($previous, $exception->getPrevious());
}
}

/**
*/
public function testContextBagErrorsIncludeStackInThrownExceptionMessage () : void
{
$normalizer = $this->createNormalizer(new class() implements SimpleObjectNormalizerInterface {
public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : null
{
$bag = new ContextBag($context);
$bag->getString("required");

return null;
}

public static function getNormalizedType () : string
{
return DummyVO::class;
}
});

foreach ([[], ["required" => 1]] as $context)
{
try
{
$normalizer->normalize(new DummyVO(11), $context);
self::fail("Expected an exception to be thrown.");
}
catch (\Throwable $exception)
{
self::assertStringContainsString(
"at Tests\Torr\SimpleNormalizer\Fixture\DummyVO",
$exception->getMessage(),
);
}
}
}

/**
*/
public function testCustomNormalizationFailedExceptionGetsStack () : void
{
$normalizer = $this->createNormalizer(new class() implements SimpleObjectNormalizerInterface {
public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : mixed
{
throw new NormalizationFailedException("Custom failure");
}

public static function getNormalizedType () : string
{
return DummyVO::class;
}
});

try
{
$normalizer->normalize(new DummyVO(11));
self::fail("Expected NormalizationFailedException to be thrown.");
}
catch (NormalizationFailedException $exception)
{
self::assertSame([DummyVO::class], $exception->getNormalizationStack());
}
}
}
Loading