From 9c4ee2b9aab124a0f9c4a1319d383fcbd534da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=A4drich?= Date: Sat, 2 Nov 2024 02:20:15 +0100 Subject: [PATCH] feat: :sparkles: Implement class-based UUID validation rule with more constraints than just the version --- src/Illuminate/Validation/Rule.php | 12 ++ src/Illuminate/Validation/Rules/Uuid.php | 203 ++++++++++++++++++++ tests/Validation/ValidationUuidRuleTest.php | 98 ++++++++++ 3 files changed, 313 insertions(+) create mode 100644 src/Illuminate/Validation/Rules/Uuid.php create mode 100644 tests/Validation/ValidationUuidRuleTest.php diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index f0483842d99c..91c37b5b1896 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -17,6 +17,7 @@ use Illuminate\Validation\Rules\ProhibitedIf; use Illuminate\Validation\Rules\RequiredIf; use Illuminate\Validation\Rules\Unique; +use Illuminate\Validation\Rules\Uuid; class Rule { @@ -210,4 +211,15 @@ public static function dimensions(array $constraints = []) { return new Dimensions($constraints); } + + /** + * Get an uuid rule builder instance. + * + * @param class-string $type + * @return \Illuminate\Validation\Rules\Uuid + */ + public static function uuid($type) + { + return new Uuid($type); + } } diff --git a/src/Illuminate/Validation/Rules/Uuid.php b/src/Illuminate/Validation/Rules/Uuid.php new file mode 100644 index 000000000000..e8af4b45e8e7 --- /dev/null +++ b/src/Illuminate/Validation/Rules/Uuid.php @@ -0,0 +1,203 @@ +|'max' + */ + protected string|int $version; + + /** + * Usually, the MAC address (can be random) + * Supported by v1, v2, and v6 + */ + protected string $node; + + /** + * @var ':'|'-' $separator + */ + protected string $separator = ':'; + + /** + * Usually, the namespace + * Supported by v3 and v5 + */ + protected string $domain; + + /** + * Usually, the namespace ID + * Supported by v3 and v5 + */ + protected string $identifier; + + /** + * Supported by v1, v2, v6, and v7 + * @var callable $dateTimeCallback + */ + protected $dateTimeCallback; + + public function passes($attribute, $value) + { + if (! Str::isUuid($value)) { + return false; + } + + $factory = new UuidFactory; + + try { + $factoryUuid = $factory->fromString($value); + } catch (InvalidUuidStringException $ex) { + return false; + } + + $fields = $factoryUuid->getFields(); + + if (! ($fields instanceof FieldsInterface)) { + return false; + } + + foreach ([ 'version', 'node', 'domain', 'identifier', 'dateTimeCallback' ] as $prop) { + if (! $this->isInitialized($prop)) { + continue; + } + + if ( + $prop === 'version' && ( + (in_array($this->version, [ 0, 'nil' ], true) && ! $fields->isNil()) || + ($this->version === 'max' && ! $fields->isMax()) || + (! in_array($this->version, [ 0, 'nil', 'max' ]) && $fields->getVersion() !== $this->version) + ) + ) { + return false; + } + + if ($prop === 'node' && in_array($fields->getVersion(), [ + UuidUuid::UUID_TYPE_TIME, + UuidUuid::UUID_TYPE_DCE_SECURITY, + UuidUuid::UUID_TYPE_REORDERED_TIME, + ]) && $this->node !== $this->formatMacAddress($fields->getNode()->toString())) { + return false; + } + + if ( + $prop === 'domain' && + $fields->getVersion() === UuidUuid::UUID_TYPE_DCE_SECURITY && + $this->toV2($fields, $factory)->getLocalDomainName() !== $this->domain + ) { + return false; + } + + if ( + $prop === 'identifier' && + $fields->getVersion() === UuidUuid::UUID_TYPE_DCE_SECURITY && + $this->toV2($fields, $factory)->getLocalIdentifier()->toString() !== $this->identifier + ) { + return false; + } + + if ( + $prop === 'dateTimeCallback' && + $this->dateTimeCallback !== null && + in_array($fields->getVersion(), [ + UuidUuid::UUID_TYPE_TIME, + UuidUuid::UUID_TYPE_DCE_SECURITY, + UuidUuid::UUID_TYPE_REORDERED_TIME, + UuidUuid::UUID_TYPE_UNIX_TIME, + ]) && + ! call_user_func($this->dateTimeCallback, Carbon::createFromId($factoryUuid)) + ) { + return false; + } + } + + return true; + } + + private function toV2(FieldsInterface $fields, UuidFactory $factory) + { + $features = new FeatureSet(); + return new UuidV2($fields, $factory->getNumberConverter(), $factory->getCodec(), $features->getTimeConverter()); + } + + private function formatMacAddress(string $macAddress): string + { + return Str::upper(implode($this->separator, str_split($macAddress, 2))); + } + + /** + * Get the validation error message. + * + * @return array + */ + public function message() + { + $message = $this->validator->getTranslator()->get('validation.uuid'); + + return $message === 'validation.uuid' + ? ['The selected UUID (:attribute) is invalid.'] + : $message; + } + + /** + * Set the current validator. + * + * @param \Illuminate\Validation\Validator $validator + * @return $this + */ + public function setValidator($validator) + { + return tap($this, fn () => $this->validator = $validator); + } + + public function version(string|int $version) + { + return tap($this, fn () => $this->version = $version); + } + + public function node(string $node) + { + return tap($this, fn () => $this->node = Str::upper($node)); + } + + public function domain(string|int $domain) + { + return tap($this, fn () => $this->domain = is_int($domain) ? UuidUuid::DCE_DOMAIN_NAMES[$domain] : $domain); + } + + public function identifier(string|int $identifier) + { + return tap($this, fn () => $this->identifier = (string)$identifier); + } + + public function dateTime(callable $callback) + { + return tap($this, fn () => $this->dateTimeCallback = $callback); + } + + private function isInitialized(string $property) + { + return (new ReflectionProperty($this, $property))->isInitialized($this); + } +} diff --git a/tests/Validation/ValidationUuidRuleTest.php b/tests/Validation/ValidationUuidRuleTest.php new file mode 100644 index 000000000000..e566c2aea5a8 --- /dev/null +++ b/tests/Validation/ValidationUuidRuleTest.php @@ -0,0 +1,98 @@ + $value, + ], + [ + 'uuid' => $rule, + ] + ); + + $this->assertFalse($v->fails()); + } + + public static function providesValidValidationDataAndRules(): array + { + return [ + [ '00000000-0000-0000-0000-000000000000', new Uuid ], + [ '145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(1) ], + [ '85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->node('02:42:fe:50:b4:b4') ], + [ '000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_PERSON)->identifier(501) ], + [ '85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->dateTime(fn (Carbon $dateTime) => $dateTime->isSameDay('2024-11-01')) ], + ]; + } + + #[DataProvider('providesInvalidValidationDataAndRules')] + public function testValidationPassesWhenPassingIncorrectUuid(string $value, Uuid $rule) + { + $v = new Validator( + resolve('translator'), + [ + 'uuid' => $value, + ], + [ + 'uuid' => $rule, + ] + ); + + $this->assertTrue($v->fails()); + } + + public static function providesInvalidValidationDataAndRules(): array + { + return [ + [ 'ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ', new Uuid ], + [ '145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(2) ], + [ '145a1e72-d11d-11e8-a8d5-f2801f1b9fd1', (new Uuid())->version(42) ], + [ '85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->node('ZZ:ZZ:ZZ:ZZ:ZZ:ZZ') ], + [ '000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_GROUP)->identifier(501) ], + [ '000001f5-5e9a-21ea-9e00-0242ac130003', (new Uuid())->version(2)->domain(UuidUuid::DCE_DOMAIN_PERSON)->identifier(42) ], + [ '85962c6a-98a5-11ef-8d2a-0242fe50b4b4', (new Uuid())->version(1)->dateTime(fn (Carbon $dateTime) => $dateTime->isSameDay('2042-01-11')) ], + ]; + } + + protected function setUp(): void + { + $container = Container::getInstance(); + + $container->bind('translator', function () { + return new Translator( + new ArrayLoader, 'en' + ); + }); + + Facade::setFacadeApplication($container); + + (new ValidationServiceProvider($container))->register(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + Facade::clearResolvedInstances(); + + Facade::setFacadeApplication(null); + } +}