diff --git a/src/Symfony/Component/Validator/Constraints/Uuid.php b/src/Symfony/Component/Validator/Constraints/Uuid.php new file mode 100644 index 0000000000..0a265f4267 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Uuid.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * @Annotation + * + * @author Colin O'Dell + */ +class Uuid extends Constraint +{ + // Possible versions defined by RFC 4122 + const V1_MAC = 1; + const V2_DCE = 2; + const V3_MD5 = 3; + const V4_RANDOM = 4; + const V5_SHA1 = 5; + + /** + * Message to display when validation fails + * + * @var string + */ + public $message = 'This is not a valid UUID.'; + + /** + * Strict mode only allows UUIDs that meet the formal definition and formatting per RFC 4122 + * + * Set this to `false` to allow legacy formats with different dash positioning or wrapping characters + * + * @var bool + */ + public $strict = true; + + /** + * Array of allowed versions (see version constants above) + * + * All UUID versions are allowed by default + * + * @var int[] + */ + public $versions = array( + self::V1_MAC, + self::V2_DCE, + self::V3_MD5, + self::V4_RANDOM, + self::V5_SHA1 + ); +} diff --git a/src/Symfony/Component/Validator/Constraints/UuidValidator.php b/src/Symfony/Component/Validator/Constraints/UuidValidator.php new file mode 100644 index 0000000000..06d8f9c107 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/UuidValidator.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates whether the value is a valid UUID per RFC 4122. + * + * @author Colin O'Dell + * + * @see http://tools.ietf.org/html/rfc4122 + * @see https://en.wikipedia.org/wiki/Universally_unique_identifier + */ +class UuidValidator extends ConstraintValidator +{ + /** + * Regular expression which verifies allowed characters and the proper format. + * + * The strict pattern matches UUIDs like this: xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx + * Roughly speaking: x = any hexadecimal character, M = any allowed version, N = any allowed variant. + */ + const STRICT_PATTERN = '/^[a-f0-9]{8}-[a-f0-9]{4}-[%s][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i'; + + /** + * The loose pattern validates similar yet non-compliant UUIDs. + * + * Dashes are completely optional. If present, they should only appear between every fourth character. + * The value can also be wrapped with characters like []{} for backwards-compatibility with other systems. + * Neither the version nor the variant is validated by this pattern. + */ + const LOOSE_PATTERN = '/^[a-f0-9]{4}(?:-?[a-f0-9]{4}){7}$/i'; + + /** + * Properly-formatted UUIDs contain 32 hex digits, separated by 4 dashes. + * We can use this fact to avoid performing a preg_match on strings of other sizes. + */ + const STRICT_UUID_LENGTH = 36; + + /** + * {@inheritDoc} + */ + public function validate($value, Constraint $constraint) + { + if (null === $value || '' === $value) { + return; + } + + if (!is_scalar($value) && !(is_object($value) && method_exists($value, '__toString'))) { + throw new UnexpectedTypeException($value, 'string'); + } + + $value = (string) $value; + + if ($constraint->strict) { + // Insert the allowed versions into the regular expression + $pattern = sprintf(static::STRICT_PATTERN, implode('', $constraint->versions)); + + if (strlen($value) !== static::STRICT_UUID_LENGTH || !preg_match($pattern, $value)) { + $this->context->addViolation($constraint->message, array('{{ value }}' => $value)); + } + } else { + // Trim any wrapping characters like [] or {} used by some legacy systems + $value = trim($value, '[]{}'); + + if (!preg_match(static::LOOSE_PATTERN, $value)) { + $this->context->addViolation($constraint->message, array('{{ value }}' => $value)); + } + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php new file mode 100644 index 0000000000..ab04aac6c7 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/UuidValidatorTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\Uuid; +use Symfony\Component\Validator\Constraints\UuidValidator; + +/** + * @author Colin O'Dell + */ +class UuidValidatorTest extends \PHPUnit_Framework_TestCase +{ + protected $context; + protected $validator; + + protected function setUp() + { + $this->context = $this->getMock('Symfony\Component\Validator\ExecutionContext', array(), array(), '', false); + $this->validator = new UuidValidator(); + $this->validator->initialize($this->context); + } + + protected function tearDown() + { + $this->context = null; + $this->validator = null; + } + + public function testNullIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate(null, new Uuid()); + } + + public function testEmptyStringIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('', new Uuid()); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException + */ + public function testExpectsStringCompatibleType() + { + $this->validator->validate(new \stdClass(), new Uuid()); + } + + /** + * @dataProvider getValidStrictUuids + */ + public function testValidStrictUuids($uuid) + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate($uuid, new Uuid()); + } + + public function getValidStrictUuids() + { + return array( + array('216fff40-98d9-11e3-a5e2-0800200c9a66'), // Version 1 UUID in lowercase + array('216FFF40-98D9-11E3-A5E2-0800200C9A66'), // Version 1 UUID in UPPERCASE + array('456daefb-5aa6-41b5-8dbc-068b05a8b201'), // Version 4 UUID in lowercase + array('456DAEFb-5AA6-41B5-8DBC-068B05A8B201'), // Version 4 UUID in UPPERCASE + ); + } + + /** + * @dataProvider getInvalidStrictUuids + */ + public function testInvalidStrictUuids($uuid) + { + $constraint = new Uuid(array( + 'message' => 'testMessage' + )); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('testMessage', array( + '{{ value }}' => $uuid, + )); + + $this->validator->validate($uuid, $constraint); + } + + public function getInvalidStrictUuids() + { + return array( + array('216fff40-98d9-11e3-a5e2-0800200c9a6'), // Too few characters + array('216fff40-98d9-11e3-a5e2-0800200c9a666'), // Too many characters + array('V16fff40-98d9-11e3-a5e2-0800200c9a66'), // Invalid character 'V' + array('2-16fff-4098d-911e3a5e20-800-200c9-a66'), // Non-standard dash positions (randomly placed) + + // Non-standard UUIDs allowed by some other systems + array('216f-ff40-98d9-11e3-a5e2-0800-200c-9a66'), // Non-standard dash positions (every 4 chars) + array('216fff40-98d911e3-a5e20800-200c9a66'), // Non-standard dash positions (every 8 chars) + array('216fff4098d911e3a5e20800200c9a66'), // No dashes at all + array('{216fff40-98d9-11e3-a5e2-0800200c9a66}'), // Wrapped with curly braces + ); + } + + /** + * @dataProvider getValidStrictUuids + */ + public function testVersionConstraintIsValid($uuid) + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $constraint = new Uuid(array( + 'versions' => array(Uuid::V1_MAC, Uuid::V4_RANDOM) + )); + + $this->validator->validate($uuid, $constraint); + } + + /** + * @dataProvider getValidStrictUuids + */ + public function testVersionConstraintIsInvalid($uuid) + { + $constraint = new Uuid(array( + 'versions' => array(Uuid::V2_DCE, Uuid::V3_MD5) + )); + + $this->context->expects($this->once()) + ->method('addViolation'); + + $this->validator->validate($uuid, $constraint); + } + + /** + * @dataProvider getValidNonStrictUuids + */ + public function testValidNonStrictUuids($uuid) + { + $constraint = new Uuid(array( + 'strict' => false + )); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate($uuid, $constraint); + } + + public function getValidNonStrictUuids() + { + return array( + array('216fff40-98d9-11e3-a5e2-0800200c9a66'), // Version 1 UUID in lowercase + array('216FFF40-98D9-11E3-A5E2-0800200C9A66'), // Version 1 UUID in UPPERCASE + array('456daefb-5aa6-41b5-8dbc-068b05a8b201'), // Version 4 UUID in lowercase + array('456DAEFb-5AA6-41B5-8DBC-068B05A8B201'), // Version 4 UUID in UPPERCASE + + // Non-standard UUIDs allowed by some other systems + array('216f-ff40-98d9-11e3-a5e2-0800-200c-9a66'), // Non-standard dash positions (every 4 chars) + array('216fff40-98d911e3-a5e20800-200c9a66'), // Non-standard dash positions (every 8 chars) + array('216fff4098d911e3a5e20800200c9a66'), // No dashes at all + array('{216fff40-98d9-11e3-a5e2-0800200c9a66}'), // Wrapped with curly braces + ); + } + + /** + * @dataProvider getInvalidNonStrictUuids + */ + public function testInvalidNonStrictUuids($uuid) + { + $constraint = new Uuid(array( + 'strict' => false + )); + + $this->context->expects($this->once()) + ->method('addViolation'); + + $this->validator->validate($uuid, $constraint); + } + + public function getInvalidNonStrictUuids() + { + return array( + array('216fff40-98d9-11e3-a5e2-0800200c9a6'), // Too few characters + array('216fff40-98d9-11e3-a5e2-0800200c9a666'), // Too many characters + array('V16fff40-98d9-11e3-a5e2-0800200c9a66'), // Invalid character 'V' + array('2-16fff-4098d-911e3a5e20-800-200c9-a66'), // Non-standard dash positions (randomly placed) + ); + } +}