From 330f0183a7568b590c115ad9e41390e6769bf67a Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Apr 2026 17:47:01 -0400 Subject: [PATCH 1/5] More PHPStan fixes. Bump to level 6. --- phpstan.neon.dist | 2 +- src/FreeDSx/Asn1/Asn1.php | 23 +++---- src/FreeDSx/Asn1/Encoder/BerEncoder.php | 64 ++++++++++--------- src/FreeDSx/Asn1/Encoder/DerEncoder.php | 13 +++- src/FreeDSx/Asn1/Encoder/EncoderInterface.php | 8 ++- src/FreeDSx/Asn1/Type/AbstractTimeType.php | 5 +- src/FreeDSx/Asn1/Type/AbstractType.php | 13 ++-- src/FreeDSx/Asn1/Type/SequenceType.php | 4 +- src/FreeDSx/Asn1/Type/SetTrait.php | 5 +- src/FreeDSx/Asn1/Type/SetType.php | 4 +- 10 files changed, 82 insertions(+), 59 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f73b1df..bd1ec69 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 4 + level: 6 paths: - %currentWorkingDirectory%/src treatPhpDocTypesAsCertain: false diff --git a/src/FreeDSx/Asn1/Asn1.php b/src/FreeDSx/Asn1/Asn1.php index 9c4f684..f547190 100644 --- a/src/FreeDSx/Asn1/Asn1.php +++ b/src/FreeDSx/Asn1/Asn1.php @@ -49,8 +49,7 @@ class Asn1 { /** - * @param AbstractType ...$types - * @return SequenceType + * @param AbstractType ...$types */ public static function sequence(AbstractType ...$types): SequenceType { @@ -58,8 +57,7 @@ public static function sequence(AbstractType ...$types): SequenceType } /** - * @param AbstractType ...$types - * @return SequenceOfType + * @param AbstractType ...$types */ public static function sequenceOf(AbstractType ...$types): SequenceOfType { @@ -295,8 +293,7 @@ public static function visibleString(string $string): VisibleStringType } /** - * @param AbstractType ...$types - * @return SetType + * @param AbstractType ...$types */ public static function set(AbstractType ...$types): SetType { @@ -304,8 +301,7 @@ public static function set(AbstractType ...$types): SetType } /** - * @param AbstractType ...$types - * @return SetOfType + * @param AbstractType ...$types */ public static function setOf(AbstractType ...$types): SetOfType { @@ -313,9 +309,10 @@ public static function setOf(AbstractType ...$types): SetOfType } /** - * @template T of AbstractType - * @param int $tagNumber + * @template T of AbstractType + * * @param T $type + * * @return T */ public static function context(int $tagNumber, AbstractType $type) @@ -324,7 +321,7 @@ public static function context(int $tagNumber, AbstractType $type) } /** - * @template T of AbstractType + * @template T of AbstractType * @param int $tagNumber * @param T $type * @return T @@ -335,7 +332,7 @@ public static function application(int $tagNumber, AbstractType $type) } /** - * @template T of AbstractType + * @template T of AbstractType * @param int $tagNumber * @param T $type * @return T @@ -346,7 +343,7 @@ public static function universal(int $tagNumber, AbstractType $type) } /** - * @template T of AbstractType + * @template T of AbstractType * @param int $tagNumber * @param T $type * @return T diff --git a/src/FreeDSx/Asn1/Encoder/BerEncoder.php b/src/FreeDSx/Asn1/Encoder/BerEncoder.php index 3913987..8c4f06c 100644 --- a/src/FreeDSx/Asn1/Encoder/BerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/BerEncoder.php @@ -135,6 +135,8 @@ public function __construct(protected EncoderOptions $options = new EncoderOptio /** * {@inheritdoc} + * + * @return AbstractType */ public function decode( string $binary, @@ -154,6 +156,8 @@ public function decode( /** * {@inheritdoc} + * + * @return AbstractType */ public function complete( IncompleteType $type, @@ -173,6 +177,8 @@ public function complete( /** * {@inheritdoc} + * + * @param AbstractType $type */ public function encode(AbstractType $type): string { @@ -312,12 +318,13 @@ protected function stopEncoding(): void } /** - * @param bool $isRoot - * @param null|int $tagType - * @param null|int $length - * @param null|bool $isConstructed - * @param null|int $class - * @return AbstractType + * @param int|null $tagType + * @param int|null $length + * @param bool|null $isConstructed + * @param int|null $class + * + * @return AbstractType + * * @throws EncoderException * @throws PartialPduException */ @@ -774,21 +781,14 @@ protected function encodeTime(AbstractTimeType $type, string $format) } return $this->formatDateTime( - clone $type->getValue(), + DateTime::createFromInterface($type->getValue()), $type->getDateTimeFormat(), $type->getTimeZoneFormat(), $format ); } - /** - * @param \DateTime $dateTime - * @param string $dateTimeFormat - * @param string $tzFormat - * @param string $format - * @return string - */ - protected function formatDateTime(DateTime $dateTime, string $dateTimeFormat, string $tzFormat, string $format) + protected function formatDateTime(DateTime $dateTime, string $dateTimeFormat, string $tzFormat, string $format): string { if ($tzFormat === GeneralizedTimeType::TZ_LOCAL) { $dateTime->setTimezone(new DateTimeZone(date_default_timezone_get())); @@ -890,7 +890,9 @@ protected function encodeReal(RealType $type) /** * @param int $length - * @return array + * + * @return array{0: DateTime, 1: string, 2: string} + * * @throws EncoderException */ protected function decodeGeneralizedTime($length): array @@ -900,7 +902,9 @@ protected function decodeGeneralizedTime($length): array /** * @param int $length - * @return array + * + * @return array{0: DateTime, 1: string, 2: string} + * * @throws EncoderException */ protected function decodeUtcTime($length): array @@ -909,11 +913,11 @@ protected function decodeUtcTime($length): array } /** - * @param string $format - * @param string $regex - * @param array $matchMap + * @param array $matchMap * @param int $length - * @return array + * + * @return array{0: DateTime, 1: string, 2: string} + * * @throws EncoderException */ protected function decodeTime(string $format, string $regex, array $matchMap, $length): array @@ -963,10 +967,10 @@ protected function decodeTime(string $format, string $regex, array $matchMap, $l /** * Some encodings have specific restrictions. Allow them to override and validate this. * - * @param array $matches - * @param array $matchMap + * @param array $matches + * @param array $matchMap */ - protected function validateDateFormat(array $matches, array $matchMap) + protected function validateDateFormat(array $matches, array $matchMap): void { } @@ -1178,11 +1182,11 @@ protected function encodeSetOf(SetOfType $set) } /** - * @param AbstractType ...$types - * @return string + * @param AbstractType ...$types + * * @throws EncoderException */ - protected function encodeConstructedType(AbstractType ...$types) + protected function encodeConstructedType(AbstractType ...$types): string { $bytes = ''; @@ -1195,11 +1199,13 @@ protected function encodeConstructedType(AbstractType ...$types) /** * @param int $length - * @return array + * + * @return list> + * * @throws EncoderException * @throws PartialPduException */ - protected function decodeConstructedType($length) + protected function decodeConstructedType($length): array { $children = []; $endAt = $this->pos + $length; diff --git a/src/FreeDSx/Asn1/Encoder/DerEncoder.php b/src/FreeDSx/Asn1/Encoder/DerEncoder.php index 840e7b6..21c832f 100644 --- a/src/FreeDSx/Asn1/Encoder/DerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/DerEncoder.php @@ -35,6 +35,9 @@ public function __construct(EncoderOptions $options = new EncoderOptions()) $this->setOptions(new EncoderOptions(bitstringPadding: '0')); } + /** + * @param AbstractType $type + */ public function encode(AbstractType $type): string { $this->validate($type); @@ -44,6 +47,13 @@ public function encode(AbstractType $type): string /** * {@inheritdoc} + * + * @param int|null $tagType + * @param int|null $length + * @param bool|null $isConstructed + * @param int|null $class + * + * @return AbstractType */ protected function decodeBytes(bool $isRoot = false, $tagType = null, $length = null, $isConstructed = null, $class = null): AbstractType { @@ -77,7 +87,8 @@ protected function encodeSet(SetType $set) } /** - * @param AbstractType $type + * @param AbstractType $type + * * @throws EncoderException */ protected function validate(AbstractType $type): void diff --git a/src/FreeDSx/Asn1/Encoder/EncoderInterface.php b/src/FreeDSx/Asn1/Encoder/EncoderInterface.php index 4f93d9d..2b3e21d 100644 --- a/src/FreeDSx/Asn1/Encoder/EncoderInterface.php +++ b/src/FreeDSx/Asn1/Encoder/EncoderInterface.php @@ -25,8 +25,8 @@ interface EncoderInterface /** * Encode a type to its binary form. * - * @param AbstractType $type - * @return string + * @param AbstractType $type + * * @throws EncoderException */ public function encode(AbstractType $type): string; @@ -36,6 +36,8 @@ public function encode(AbstractType $type): string; * * @param array> $tagMap Tag class => (tag number => universal tag type). * + * @return AbstractType + * * @throws EncoderException */ public function complete(IncompleteType $type, int $tagType, array $tagMap = []): AbstractType; @@ -45,6 +47,8 @@ public function complete(IncompleteType $type, int $tagType, array $tagMap = []) * * @param array> $tagMap Tag class => (tag number => universal tag type). * + * @return AbstractType + * * @throws EncoderException * @throws PartialPduException */ diff --git a/src/FreeDSx/Asn1/Type/AbstractTimeType.php b/src/FreeDSx/Asn1/Type/AbstractTimeType.php index 7591d07..3251b89 100644 --- a/src/FreeDSx/Asn1/Type/AbstractTimeType.php +++ b/src/FreeDSx/Asn1/Type/AbstractTimeType.php @@ -17,6 +17,8 @@ /** * Generalized / UTC time type. * + * @extends AbstractType + * * @author Chad Sikorra */ class AbstractTimeType extends AbstractType @@ -87,8 +89,7 @@ public function __construct( ) { $this->setDateTimeFormat($dateFormat); $this->setTimeZoneFormat($tzFormat); - parent::__construct(null); - $this->value = $dateTime ?? new DateTime(); + parent::__construct($dateTime ?? new DateTime()); } /** diff --git a/src/FreeDSx/Asn1/Type/AbstractType.php b/src/FreeDSx/Asn1/Type/AbstractType.php index 6ab52b0..90bfc22 100644 --- a/src/FreeDSx/Asn1/Type/AbstractType.php +++ b/src/FreeDSx/Asn1/Type/AbstractType.php @@ -117,7 +117,7 @@ abstract class AbstractType implements Countable, IteratorAggregate protected $isConstructed = false; /** - * @var array + * @var array> */ protected $children = []; @@ -193,7 +193,7 @@ public function hasChild(int $index): bool } /** - * @param AbstractType ...$types + * @param AbstractType ...$types * * @return $this */ @@ -205,20 +205,23 @@ public function setChildren(...$types) } /** - * @return array + * @return array> */ public function getChildren(): array { return $this->children; } + /** + * @return AbstractType|null + */ public function getChild(int $index): ?AbstractType { return $this->children[$index] ?? null; } /** - * @param AbstractType ...$types + * @param AbstractType ...$types * * @return $this */ @@ -237,7 +240,7 @@ public function count(): int } /** - * @return ArrayIterator + * @return ArrayIterator> */ public function getIterator(): ArrayIterator { diff --git a/src/FreeDSx/Asn1/Type/SequenceType.php b/src/FreeDSx/Asn1/Type/SequenceType.php index f4e8f3b..c370d88 100644 --- a/src/FreeDSx/Asn1/Type/SequenceType.php +++ b/src/FreeDSx/Asn1/Type/SequenceType.php @@ -30,7 +30,7 @@ class SequenceType extends AbstractType protected $isConstructed = true; /** - * @param AbstractType ...$types + * @param AbstractType ...$types */ public function __construct(...$types) { @@ -40,7 +40,7 @@ public function __construct(...$types) /** * @param int|string $tagNumber - * @param array $children + * @param array> $children * * @return static */ diff --git a/src/FreeDSx/Asn1/Type/SetTrait.php b/src/FreeDSx/Asn1/Type/SetTrait.php index 8e37a90..54bbdac 100644 --- a/src/FreeDSx/Asn1/Type/SetTrait.php +++ b/src/FreeDSx/Asn1/Type/SetTrait.php @@ -28,8 +28,9 @@ trait SetTrait * - Private classes last. * - Within each group of classes above, tag numbers should be ordered in ascending order. * - * @param AbstractType ...$set - * @return AbstractType[] + * @param AbstractType ...$set + * + * @return list> */ protected function canonicalize(AbstractType ...$set): array { diff --git a/src/FreeDSx/Asn1/Type/SetType.php b/src/FreeDSx/Asn1/Type/SetType.php index 9cb651e..b70d6a5 100644 --- a/src/FreeDSx/Asn1/Type/SetType.php +++ b/src/FreeDSx/Asn1/Type/SetType.php @@ -32,7 +32,7 @@ class SetType extends AbstractType protected $isConstructed = true; /** - * @param AbstractType ...$types + * @param AbstractType ...$types */ public function __construct(...$types) { @@ -52,7 +52,7 @@ public function isCanonical(): bool /** * @param int|string $tagNumber - * @param array $children + * @param array> $children * * @return static */ From 52e39a165b71ecc0af34ff74229c99fdd1f3d783 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Apr 2026 18:02:45 -0400 Subject: [PATCH 2/5] Remove the variadic usage. It's too performance impacting with lots of data. --- src/FreeDSx/Asn1/Encoder/BerEncoder.php | 18 ++++++-------- src/FreeDSx/Asn1/Encoder/DerEncoder.php | 5 ++-- src/FreeDSx/Asn1/Type/SetTrait.php | 4 ++-- src/FreeDSx/Asn1/Type/SetType.php | 2 +- tests/performance/baseline-floor.json | 32 ++++++++++++------------- 5 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/FreeDSx/Asn1/Encoder/BerEncoder.php b/src/FreeDSx/Asn1/Encoder/BerEncoder.php index 8c4f06c..5aa9b7d 100644 --- a/src/FreeDSx/Asn1/Encoder/BerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/BerEncoder.php @@ -203,7 +203,7 @@ public function encode(AbstractType $type): string $bytes = $this->encodeSet($type); break; case $type->getIsConstructed(): - $bytes = $this->encodeConstructedType(...$type->getChildren()); + $bytes = $this->encodeConstructedType($type->getChildren()); break; case $type instanceof BitStringType: $bytes = $this->encodeBitString($type); @@ -1160,33 +1160,29 @@ protected function decodeReal($length): float /** * Encoding subsets may require specific ordering on set types. Allow this to be overridden. * - * @param SetType $set - * @return string * @throws EncoderException */ - protected function encodeSet(SetType $set) + protected function encodeSet(SetType $set): string { - return $this->encodeConstructedType(...$set->getChildren()); + return $this->encodeConstructedType($set->getChildren()); } /** * Encoding subsets may require specific ordering on set of types. Allow this to be overridden. * - * @param SetOfType $set - * @return string * @throws EncoderException */ - protected function encodeSetOf(SetOfType $set) + protected function encodeSetOf(SetOfType $set): string { - return $this->encodeConstructedType(...$set->getChildren()); + return $this->encodeConstructedType($set->getChildren()); } /** - * @param AbstractType ...$types + * @param array> $types * * @throws EncoderException */ - protected function encodeConstructedType(AbstractType ...$types): string + protected function encodeConstructedType(array $types): string { $bytes = ''; diff --git a/src/FreeDSx/Asn1/Encoder/DerEncoder.php b/src/FreeDSx/Asn1/Encoder/DerEncoder.php index 21c832f..2b83d2e 100644 --- a/src/FreeDSx/Asn1/Encoder/DerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/DerEncoder.php @@ -79,11 +79,12 @@ protected function decodeLongDefiniteLength(int $length): int /** * {@inheritdoc} + * * @throws EncoderException */ - protected function encodeSet(SetType $set) + protected function encodeSet(SetType $set): string { - return $this->encodeConstructedType(...$this->canonicalize(...$set->getChildren())); + return $this->encodeConstructedType($this->canonicalize($set->getChildren())); } /** diff --git a/src/FreeDSx/Asn1/Type/SetTrait.php b/src/FreeDSx/Asn1/Type/SetTrait.php index 54bbdac..32a214a 100644 --- a/src/FreeDSx/Asn1/Type/SetTrait.php +++ b/src/FreeDSx/Asn1/Type/SetTrait.php @@ -28,11 +28,11 @@ trait SetTrait * - Private classes last. * - Within each group of classes above, tag numbers should be ordered in ascending order. * - * @param AbstractType ...$set + * @param array> $set * * @return list> */ - protected function canonicalize(AbstractType ...$set): array + protected function canonicalize(array $set): array { $children = [ AbstractType::TAG_CLASS_UNIVERSAL => [], diff --git a/src/FreeDSx/Asn1/Type/SetType.php b/src/FreeDSx/Asn1/Type/SetType.php index b70d6a5..4a74148 100644 --- a/src/FreeDSx/Asn1/Type/SetType.php +++ b/src/FreeDSx/Asn1/Type/SetType.php @@ -47,7 +47,7 @@ public function __construct(...$types) */ public function isCanonical(): bool { - return $this->children === $this->canonicalize(...$this->children); + return $this->children === $this->canonicalize($this->children); } /** diff --git a/tests/performance/baseline-floor.json b/tests/performance/baseline-floor.json index c7ba4c7..946f572 100644 --- a/tests/performance/baseline-floor.json +++ b/tests/performance/baseline-floor.json @@ -4,33 +4,33 @@ "runner": "ubuntu-latest", "php": "8.5", "margin_pct": 20, - "note": "Captured from a GitHub Actions ubuntu-latest perf run. Each value = measured ns/payload * 1.20 (rounded up). Refresh via `composer bench-update-floor` after intentional perf changes or runner-generation upgrades." + "note": "Refreshed after the encoder hot-path variadic-to-array conversion (encodeConstructedType / canonicalize). Each value = measured ns/payload * 1.20 (rounded up). Refresh via `composer bench-update-floor` on a clean ubuntu-latest run after intentional perf changes or runner-generation upgrades." }, "floor_ns_per_payload": { "ldap_search_entry": { - "encode": 17660, - "decode": 12075, - "round_trip": 30578 + "encode": 15372, + "decode": 12062, + "round_trip": 27561 }, "string_heavy": { - "encode": 2664, - "decode": 2492, - "round_trip": 5643 + "encode": 2375, + "decode": 2465, + "round_trip": 5188 }, "oid_heavy": { - "encode": 9826, - "decode": 6677, - "round_trip": 17037 + "encode": 8991, + "decode": 6788, + "round_trip": 16480 }, "integer_heavy": { - "encode": 2378, - "decode": 1948, - "round_trip": 4704 + "encode": 2094, + "decode": 1806, + "round_trip": 4236 }, "mixed_message": { - "encode": 23906, - "decode": 15995, - "round_trip": 41120 + "encode": 20069, + "decode": 16163, + "round_trip": 37341 } } } From b3ba82421ddd0c4da964d861b9989f5770431c99 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Apr 2026 18:41:42 -0400 Subject: [PATCH 3/5] Use match during encode to get a performance gain from a JMP_TABLE. Additionally, cache some values to avoid constantly calling some methods multiple times. --- .github/workflows/perf.yml | 9 +- src/FreeDSx/Asn1/Encoder/BerEncoder.php | 116 +++++++++++++----------- tests/performance/baseline-floor.json | 36 ++++---- tests/unit/Encoder/BerEncoderTest.php | 8 ++ 4 files changed, 97 insertions(+), 72 deletions(-) diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index fe04c9d..62e2cf7 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -145,11 +145,18 @@ jobs: command: composer install --no-progress --prefer-dist --optimize-autoloader - name: Run bench-vs-floor + id: floor_check run: composer bench-vs-floor | tee /tmp/floor-result.txt + - name: Capture candidate floor + if: steps.floor_check.outcome == 'success' + run: composer bench-update-floor + - name: Upload artifact if: always() uses: actions/upload-artifact@v4 with: name: perf-floor-${{ github.run_number }} - path: /tmp/floor-result.txt + path: | + /tmp/floor-result.txt + tests/performance/baseline-floor.json diff --git a/src/FreeDSx/Asn1/Encoder/BerEncoder.php b/src/FreeDSx/Asn1/Encoder/BerEncoder.php index 5aa9b7d..80967a8 100644 --- a/src/FreeDSx/Asn1/Encoder/BerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/BerEncoder.php @@ -15,23 +15,37 @@ use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Asn1\Exception\InvalidArgumentException; use FreeDSx\Asn1\Exception\PartialPduException; -use FreeDSx\Asn1\Type\AbstractStringType; use FreeDSx\Asn1\Type\AbstractTimeType; use FreeDSx\Asn1\Type\AbstractType; use FreeDSx\Asn1\Type as EncodedType; use FreeDSx\Asn1\Type\BitStringType; +use FreeDSx\Asn1\Type\BmpStringType; use FreeDSx\Asn1\Type\BooleanType; +use FreeDSx\Asn1\Type\CharacterStringType; use FreeDSx\Asn1\Type\EnumeratedType; use FreeDSx\Asn1\Type\GeneralizedTimeType; +use FreeDSx\Asn1\Type\GeneralStringType; +use FreeDSx\Asn1\Type\GraphicStringType; +use FreeDSx\Asn1\Type\IA5StringType; use FreeDSx\Asn1\Type\IncompleteType; use FreeDSx\Asn1\Type\IntegerType; use FreeDSx\Asn1\Type\NullType; +use FreeDSx\Asn1\Type\NumericStringType; +use FreeDSx\Asn1\Type\OctetStringType; use FreeDSx\Asn1\Type\OidType; +use FreeDSx\Asn1\Type\PrintableStringType; use FreeDSx\Asn1\Type\RealType; use FreeDSx\Asn1\Type\RelativeOidType; +use FreeDSx\Asn1\Type\SequenceOfType; +use FreeDSx\Asn1\Type\SequenceType; use FreeDSx\Asn1\Type\SetOfType; use FreeDSx\Asn1\Type\SetType; +use FreeDSx\Asn1\Type\TeletexStringType; +use FreeDSx\Asn1\Type\UniversalStringType; use FreeDSx\Asn1\Type\UtcTimeType; +use FreeDSx\Asn1\Type\Utf8StringType; +use FreeDSx\Asn1\Type\VideotexStringType; +use FreeDSx\Asn1\Type\VisibleStringType; use function bin2hex; use function bindec; use function chr; @@ -182,67 +196,63 @@ public function complete( */ public function encode(AbstractType $type): string { - switch ($type) { - case $type instanceof BooleanType: - $bytes = $type->getValue() ? self::BOOL_TRUE : self::BOOL_FALSE; - break; - case $type instanceof IntegerType: - case $type instanceof EnumeratedType: - $bytes = $this->encodeInteger($type); - break; - case $type instanceof RealType: - $bytes = $this->encodeReal($type); - break; - case $type instanceof AbstractStringType: - $bytes = $type->getValue(); - break; - case $type instanceof SetOfType: - $bytes = $this->encodeSetOf($type); - break; - case $type instanceof SetType: - $bytes = $this->encodeSet($type); - break; - case $type->getIsConstructed(): - $bytes = $this->encodeConstructedType($type->getChildren()); - break; - case $type instanceof BitStringType: - $bytes = $this->encodeBitString($type); - break; - case $type instanceof OidType: - $bytes = $this->encodeOid($type); - break; - case $type instanceof RelativeOidType: - $bytes = $this->encodeRelativeOid($type); - break; - case $type instanceof GeneralizedTimeType: - $bytes = $this->encodeGeneralizedTime($type); - break; - case $type instanceof UtcTimeType: - $bytes = $this->encodeUtcTime($type); - break; - case $type instanceof NullType: - $bytes = ''; - break; - default: - throw new EncoderException(sprintf( - 'The type "%s" is not currently supported.', - get_class($type) - )); - } + # match($type::class) compiles to JMP_TABLE — O(1), so this is strictly for performance. + $tagNumber = $type->getTagNumber(); + $isConstructed = $type->getIsConstructed(); + + $bytes = match ($type::class) { + OctetStringType::class, + Utf8StringType::class, + IA5StringType::class, + PrintableStringType::class, + NumericStringType::class, + GeneralStringType::class, + GraphicStringType::class, + VisibleStringType::class, + BmpStringType::class, + UniversalStringType::class, + TeletexStringType::class, + VideotexStringType::class, + CharacterStringType::class => $type->getValue(), + + SequenceType::class, + SequenceOfType::class => $this->encodeConstructedType($type->getChildren()), + + IntegerType::class, + EnumeratedType::class => $this->encodeInteger($type), + + SetOfType::class => $this->encodeSetOf($type), + SetType::class => $this->encodeSet($type), + + BooleanType::class => $type->getValue() ? self::BOOL_TRUE : self::BOOL_FALSE, + NullType::class => '', + OidType::class => $this->encodeOid($type), + BitStringType::class => $this->encodeBitString($type), + RelativeOidType::class => $this->encodeRelativeOid($type), + GeneralizedTimeType::class => $this->encodeGeneralizedTime($type), + UtcTimeType::class => $this->encodeUtcTime($type), + RealType::class => $this->encodeReal($type), + + default => throw new EncoderException(sprintf( + 'The type "%s" is not currently supported.', + $type::class, + )), + }; + $length = strlen($bytes); $bytes = ($length < 128) ? chr($length) . $bytes : $this->encodeLongDefiniteLength($length) . $bytes; # The first byte of a tag always contains the class (bits 8 and 7) and whether it is constructed (bit 6). - $tag = $type->getTagClass() | ($type->getIsConstructed() ? AbstractType::CONSTRUCTED_TYPE : 0); + $tag = $type->getTagClass() | ($isConstructed ? AbstractType::CONSTRUCTED_TYPE : 0); - $this->validateNumericInt($type->getTagNumber()); + $this->validateNumericInt($tagNumber); # For a high tag (>=31) we flip the first 5 bits on (0x1f) to make the first byte, then the subsequent bytes is # the VLV encoding of the tag number. - if ($type->getTagNumber() >= 31) { - $bytes = chr($tag | 0x1f) . $this->intToVlqBytes($type->getTagNumber()) . $bytes; + if ($tagNumber >= 31) { + $bytes = chr($tag | 0x1f) . $this->intToVlqBytes($tagNumber) . $bytes; # For a tag less than 31, everything fits comfortably into a single byte. } else { - $bytes = chr($tag | $type->getTagNumber()) . $bytes; + $bytes = chr($tag | $tagNumber) . $bytes; } return $bytes; diff --git a/tests/performance/baseline-floor.json b/tests/performance/baseline-floor.json index 946f572..43314f3 100644 --- a/tests/performance/baseline-floor.json +++ b/tests/performance/baseline-floor.json @@ -1,36 +1,36 @@ { "meta": { "captured_on": "2026-04-26", - "runner": "ubuntu-latest", - "php": "8.5", + "runner": "linux", + "php": "8.5.5", "margin_pct": 20, - "note": "Refreshed after the encoder hot-path variadic-to-array conversion (encodeConstructedType / canonicalize). Each value = measured ns/payload * 1.20 (rounded up). Refresh via `composer bench-update-floor` on a clean ubuntu-latest run after intentional perf changes or runner-generation upgrades." + "note": "Captured 2026-04-26T22:42:22+00:00 with samples=15 revs=5. Each value = measured ns/payload * 1.20." }, "floor_ns_per_payload": { "ldap_search_entry": { - "encode": 15372, - "decode": 12062, - "round_trip": 27561 + "encode": 7761, + "decode": 10127, + "round_trip": 18656 }, "string_heavy": { - "encode": 2375, - "decode": 2465, - "round_trip": 5188 + "encode": 1137, + "decode": 2067, + "round_trip": 3552 }, "oid_heavy": { - "encode": 8991, - "decode": 6788, - "round_trip": 16480 + "encode": 6718, + "decode": 5817, + "round_trip": 13097 }, "integer_heavy": { - "encode": 2094, - "decode": 1806, - "round_trip": 4236 + "encode": 1512, + "decode": 1607, + "round_trip": 3430 }, "mixed_message": { - "encode": 20069, - "decode": 16163, - "round_trip": 37341 + "encode": 10095, + "decode": 13149, + "round_trip": 24199 } } } diff --git a/tests/unit/Encoder/BerEncoderTest.php b/tests/unit/Encoder/BerEncoderTest.php index 6e15d8a..a8dbd12 100644 --- a/tests/unit/Encoder/BerEncoderTest.php +++ b/tests/unit/Encoder/BerEncoderTest.php @@ -126,6 +126,14 @@ public function test_it_should_not_allow_long_definite_length_greater_than_or_eq $this->subject->decode(hex2bin('04ff')); } + public function test_it_should_throw_when_encoding_an_unsupported_type(): void + { + $this->expectException(EncoderException::class); + $this->expectExceptionMessageMatches('/is not currently supported/'); + + $this->subject->encode(new IncompleteType("\x00")); + } + public function test_it_should_decode_a_boolean_true_type(): void { self::assertEquals( From ec58d88c61c9f81a93b0b893a57d06968baaff90 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Apr 2026 19:13:16 -0400 Subject: [PATCH 4/5] Cache OID encode / decoding to improve performance. --- src/FreeDSx/Asn1/Encoder/BerEncoder.php | 106 ++++++++++++++++++++++-- tests/performance/baseline-floor.json | 34 ++++---- tests/unit/Encoder/BerEncoderTest.php | 30 +++++++ 3 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/FreeDSx/Asn1/Encoder/BerEncoder.php b/src/FreeDSx/Asn1/Encoder/BerEncoder.php index 80967a8..69d10de 100644 --- a/src/FreeDSx/Asn1/Encoder/BerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/BerEncoder.php @@ -106,6 +106,11 @@ class BerEncoder implements EncoderInterface */ protected const MAX_SECOND_COMPONENT = PHP_INT_MAX - 80; + /** + * Per-cache entry cap for OID memoization. + */ + private const OID_CACHE_MAX = 1024; + /** * @var array> Tag class => (tag number => universal tag type). */ @@ -122,6 +127,26 @@ class BerEncoder implements EncoderInterface protected bool $isGmpAvailable; + /** + * @var array Dotted OID value => encoded bytes. + */ + private static array $oidEncodeCache = []; + + /** + * @var array Encoded bytes => dotted OID value. + */ + private static array $oidDecodeCache = []; + + /** + * @var array Dotted relative OID value => encoded bytes. + */ + private static array $relativeOidEncodeCache = []; + + /** + * @var array Encoded bytes => dotted relative OID value. + */ + private static array $relativeOidDecodeCache = []; + /** * @var int */ @@ -702,7 +727,23 @@ protected function encodeBitString(BitStringType $type) */ protected function encodeRelativeOid(RelativeOidType $type) { - $oids = explode('.', $type->getValue()); + $value = $type->getValue(); + if (isset(self::$relativeOidEncodeCache[$value])) { + return self::$relativeOidEncodeCache[$value]; + } + if (count(self::$relativeOidEncodeCache) >= self::OID_CACHE_MAX) { + unset(self::$relativeOidEncodeCache[array_key_first(self::$relativeOidEncodeCache)]); + } + + return self::$relativeOidEncodeCache[$value] = $this->doEncodeRelativeOid($value); + } + + /** + * @throws EncoderException + */ + private function doEncodeRelativeOid(string $value): string + { + $oids = explode('.', $value); $bytes = ''; foreach ($oids as $oid) { @@ -718,14 +759,30 @@ protected function encodeRelativeOid(RelativeOidType $type) * @throws EncoderException */ protected function encodeOid(OidType $type) + { + $value = $type->getValue(); + if (isset(self::$oidEncodeCache[$value])) { + return self::$oidEncodeCache[$value]; + } + if (count(self::$oidEncodeCache) >= self::OID_CACHE_MAX) { + unset(self::$oidEncodeCache[array_key_first(self::$oidEncodeCache)]); + } + + return self::$oidEncodeCache[$value] = $this->doEncodeOid($value); + } + + /** + * @throws EncoderException + */ + private function doEncodeOid(string $value): string { /** @var int[] $oids */ - $oids = explode('.', $type->getValue()); + $oids = explode('.', $value); $length = count($oids); if ($length < 2) { throw new EncoderException(sprintf( 'To encode the OID it must have at least 2 components: %s', - $type->getValue() + $value )); } if ($oids[0] > 2) { @@ -994,6 +1051,18 @@ protected function decodeOid($length): string if ($length === 0) { throw new EncoderException('Zero length not permitted for an OID type.'); } + + $bytes = substr( + (string) $this->binary, + $this->pos, + $length, + ); + if (isset(self::$oidDecodeCache[$bytes])) { + $this->pos += $length; + + return self::$oidDecodeCache[$bytes]; + } + # We need to get the first part here, as it's used to determine the first 2 components. $startedAt = $this->pos; $firstPart = $this->getVlqBytesToInt(); @@ -1010,9 +1079,13 @@ protected function decodeOid($length): string # We could potentially have nothing left to decode at this point. $oidLength = $length - ($this->pos - $startedAt); - $subIdentifiers = ($oidLength === 0) ? '' : '.' . $this->decodeRelativeOid($oidLength); + $subIdentifiers = ($oidLength === 0) ? '' : '.' . $this->decodeRelativeOidValue($oidLength); + + if (count(self::$oidDecodeCache) >= self::OID_CACHE_MAX) { + unset(self::$oidDecodeCache[array_key_first(self::$oidDecodeCache)]); + } - return $oid . $subIdentifiers; + return self::$oidDecodeCache[$bytes] = $oid . $subIdentifiers; } /** @@ -1025,6 +1098,29 @@ protected function decodeRelativeOid($length): string if ($length === 0) { throw new EncoderException('Zero length not permitted for an OID type.'); } + + $bytes = substr( + (string) $this->binary, + $this->pos, + $length, + ); + if (isset(self::$relativeOidDecodeCache[$bytes])) { + $this->pos += $length; + + return self::$relativeOidDecodeCache[$bytes]; + } + if (count(self::$relativeOidDecodeCache) >= self::OID_CACHE_MAX) { + unset(self::$relativeOidDecodeCache[array_key_first(self::$relativeOidDecodeCache)]); + } + + return self::$relativeOidDecodeCache[$bytes] = $this->decodeRelativeOidValue($length); + } + + /** + * @throws EncoderException + */ + private function decodeRelativeOidValue(int $length): string + { $oid = ''; $endAt = $this->pos + $length; diff --git a/tests/performance/baseline-floor.json b/tests/performance/baseline-floor.json index 43314f3..b07ae51 100644 --- a/tests/performance/baseline-floor.json +++ b/tests/performance/baseline-floor.json @@ -4,33 +4,33 @@ "runner": "linux", "php": "8.5.5", "margin_pct": 20, - "note": "Captured 2026-04-26T22:42:22+00:00 with samples=15 revs=5. Each value = measured ns/payload * 1.20." + "note": "Captured 2026-04-26T23:15:06+00:00 with samples=15 revs=5. Each value = measured ns/payload * 1.20." }, "floor_ns_per_payload": { "ldap_search_entry": { - "encode": 7761, - "decode": 10127, - "round_trip": 18656 + "encode": 9092, + "decode": 12210, + "round_trip": 21870 }, "string_heavy": { - "encode": 1137, - "decode": 2067, - "round_trip": 3552 + "encode": 1327, + "decode": 2427, + "round_trip": 4017 }, "oid_heavy": { - "encode": 6718, - "decode": 5817, - "round_trip": 13097 + "encode": 2164, + "decode": 2456, + "round_trip": 4974 }, "integer_heavy": { - "encode": 1512, - "decode": 1607, - "round_trip": 3430 + "encode": 1649, + "decode": 1778, + "round_trip": 3813 }, "mixed_message": { - "encode": 10095, - "decode": 13149, - "round_trip": 24199 + "encode": 11242, + "decode": 15971, + "round_trip": 28198 } } -} +} \ No newline at end of file diff --git a/tests/unit/Encoder/BerEncoderTest.php b/tests/unit/Encoder/BerEncoderTest.php index a8dbd12..32bfacf 100644 --- a/tests/unit/Encoder/BerEncoderTest.php +++ b/tests/unit/Encoder/BerEncoderTest.php @@ -134,6 +134,36 @@ public function test_it_should_throw_when_encoding_an_unsupported_type(): void $this->subject->encode(new IncompleteType("\x00")); } + public function test_it_should_return_consistent_results_when_encoding_and_decoding_the_same_oid_twice(): void + { + $oid = '1.3.6.1.4.1.311.21.20'; + $first = $this->subject->encode(new OidType($oid)); + $second = $this->subject->encode(new OidType($oid)); + + self::assertSame($first, $second); + + $firstDecoded = $this->subject->decode($first); + $secondDecoded = $this->subject->decode($first); + + self::assertSame($oid, $firstDecoded->getValue()); + self::assertSame($oid, $secondDecoded->getValue()); + } + + public function test_it_should_return_consistent_results_when_encoding_and_decoding_the_same_relative_oid_twice(): void + { + $oid = '8571.3.2'; + $first = $this->subject->encode(new RelativeOidType($oid)); + $second = $this->subject->encode(new RelativeOidType($oid)); + + self::assertSame($first, $second); + + $firstDecoded = $this->subject->decode($first); + $secondDecoded = $this->subject->decode($first); + + self::assertSame($oid, $firstDecoded->getValue()); + self::assertSame($oid, $secondDecoded->getValue()); + } + public function test_it_should_decode_a_boolean_true_type(): void { self::assertEquals( From 54cae34f5a05d526209bf5c67faeae0134a82b79 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Apr 2026 19:32:35 -0400 Subject: [PATCH 5/5] Avoid more unnecessary checks and function calls. Cache certain values locally. --- src/FreeDSx/Asn1/Encoder/BerEncoder.php | 34 ++++++++++++++++--------- tests/performance/baseline-floor.json | 34 ++++++++++++------------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/FreeDSx/Asn1/Encoder/BerEncoder.php b/src/FreeDSx/Asn1/Encoder/BerEncoder.php index 69d10de..845bb97 100644 --- a/src/FreeDSx/Asn1/Encoder/BerEncoder.php +++ b/src/FreeDSx/Asn1/Encoder/BerEncoder.php @@ -270,17 +270,19 @@ public function encode(AbstractType $type): string # The first byte of a tag always contains the class (bits 8 and 7) and whether it is constructed (bit 6). $tag = $type->getTagClass() | ($isConstructed ? AbstractType::CONSTRUCTED_TYPE : 0); + # Most things have an int tag < 31. + # This lets us skip numeric int validation in the vast majority of cases. + if (is_int($tagNumber) && $tagNumber < 31) { + return chr($tag | $tagNumber) . $bytes; + } + $this->validateNumericInt($tagNumber); - # For a high tag (>=31) we flip the first 5 bits on (0x1f) to make the first byte, then the subsequent bytes is - # the VLV encoding of the tag number. + # High tag (>=31): set the first 5 bits to 0x1f, then VLQ-encode the tag number. if ($tagNumber >= 31) { - $bytes = chr($tag | 0x1f) . $this->intToVlqBytes($tagNumber) . $bytes; - # For a tag less than 31, everything fits comfortably into a single byte. - } else { - $bytes = chr($tag | $tagNumber) . $bytes; + return chr($tag | 0x1f) . $this->intToVlqBytes($tagNumber) . $bytes; } - return $bytes; + return chr($tag | (int) $tagNumber) . $bytes; } /** @@ -583,8 +585,11 @@ protected function getVlqBytesToInt() $value = 0; $lshift = 0; $isBigInt = false; + $binary = (string) $this->binary; + $pos = $this->pos; + $maxLen = $this->maxLen; - for ($this->pos; $this->pos < $this->maxLen; $this->pos++) { + for (; $pos < $maxLen; $pos++) { if (!$isBigInt) { $lshift = $value << 7; # An overflow bitshift will result in a negative number or zero. @@ -598,20 +603,25 @@ protected function getVlqBytesToInt() if ($isBigInt) { $lshift = gmp_mul($value, gmp_pow('2', 7)); } - $orVal = (ord($this->binary[$this->pos]) & 0x7f); + $byte = ord($binary[$pos]); + $orVal = $byte & 0x7f; if ($isBigInt) { $value = gmp_or($lshift, gmp_init($orVal)); } else { $value = $lshift | $orVal; } # We have reached the last byte if the MSB is not set. - if ((ord($this->binary[$this->pos]) & 0x80) === 0) { - $this->pos++; + if (($byte & 0x80) === 0) { + $this->pos = $pos + 1; - return $isBigInt ? gmp_strval($value) : $value; + return $isBigInt + ? gmp_strval($value) + : $value; } } + $this->pos = $pos; + throw new EncoderException('Expected an ending byte to decode a VLQ, but none was found.'); } diff --git a/tests/performance/baseline-floor.json b/tests/performance/baseline-floor.json index b07ae51..30f8251 100644 --- a/tests/performance/baseline-floor.json +++ b/tests/performance/baseline-floor.json @@ -4,33 +4,33 @@ "runner": "linux", "php": "8.5.5", "margin_pct": 20, - "note": "Captured 2026-04-26T23:15:06+00:00 with samples=15 revs=5. Each value = measured ns/payload * 1.20." + "note": "Captured 2026-04-26T23:33:28+00:00 with samples=15 revs=5. Each value = measured ns/payload * 1.20." }, "floor_ns_per_payload": { "ldap_search_entry": { - "encode": 9092, - "decode": 12210, - "round_trip": 21870 + "encode": 8401, + "decode": 12690, + "round_trip": 22045 }, "string_heavy": { - "encode": 1327, - "decode": 2427, - "round_trip": 4017 + "encode": 1128, + "decode": 2618, + "round_trip": 4140 }, "oid_heavy": { - "encode": 2164, - "decode": 2456, - "round_trip": 4974 + "encode": 2042, + "decode": 2611, + "round_trip": 5118 }, "integer_heavy": { - "encode": 1649, - "decode": 1778, - "round_trip": 3813 + "encode": 1716, + "decode": 1927, + "round_trip": 4040 }, "mixed_message": { - "encode": 11242, - "decode": 15971, - "round_trip": 28198 + "encode": 10997, + "decode": 17263, + "round_trip": 29051 } } -} \ No newline at end of file +}