From 0ba1ffb0297f3d4d15dfadd739b1b040361ba91b Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Sat, 5 Nov 2022 16:28:33 -0500 Subject: [PATCH] fix: ensure monotonicity for version 7 UUIDs --- composer.json | 4 +- src/FeatureSet.php | 14 +- src/Generator/UnixTimeGenerator.php | 149 ++++++++++++++-- src/Type/Time.php | 2 +- src/UuidFactory.php | 19 +-- tests/Generator/UnixTimeGeneratorTest.php | 199 ++++++++++++++++++++-- tests/Type/TimeTest.php | 4 +- tests/UuidTest.php | 59 +++++++ 8 files changed, 384 insertions(+), 66 deletions(-) diff --git a/composer.json b/composer.json index ec7ad3b..74490af 100644 --- a/composer.json +++ b/composer.json @@ -90,8 +90,8 @@ "phpcbf": "phpcbf -vpw --cache=build/cache/phpcs.cache", "phpcs": "phpcs --cache=build/cache/phpcs.cache", "phpstan": [ - "phpstan analyse --no-progress", - "phpstan analyse -c phpstan-tests.neon --no-progress" + "phpstan analyse --no-progress --memory-limit=1G", + "phpstan analyse -c phpstan-tests.neon --no-progress --memory-limit=1G" ], "phpunit": "phpunit --verbose --colors=always", "phpunit-coverage": "phpunit --verbose --colors=always --coverage-html build/coverage", diff --git a/src/FeatureSet.php b/src/FeatureSet.php index 6c8ccb0..b9af869 100644 --- a/src/FeatureSet.php +++ b/src/FeatureSet.php @@ -23,7 +23,6 @@ use Ramsey\Uuid\Converter\Number\GenericNumberConverter; use Ramsey\Uuid\Converter\NumberConverterInterface; use Ramsey\Uuid\Converter\Time\GenericTimeConverter; use Ramsey\Uuid\Converter\Time\PhpTimeConverter; -use Ramsey\Uuid\Converter\Time\UnixTimeConverter; use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Generator\DceSecurityGenerator; use Ramsey\Uuid\Generator\DceSecurityGeneratorInterface; @@ -105,7 +104,7 @@ class FeatureSet $this->validator = new GenericValidator(); assert($this->timeProvider !== null); - $this->unixTimeGenerator = $this->buildUnixTimeGenerator($this->timeProvider); + $this->unixTimeGenerator = $this->buildUnixTimeGenerator(); } /** @@ -339,17 +338,10 @@ class FeatureSet /** * Returns a Unix Epoch time generator configured for this environment - * - * @param TimeProviderInterface $timeProvider The time provider to use with - * the time generator */ - private function buildUnixTimeGenerator(TimeProviderInterface $timeProvider): TimeGeneratorInterface + private function buildUnixTimeGenerator(): TimeGeneratorInterface { - return new UnixTimeGenerator( - new UnixTimeConverter(new BrickMathCalculator()), - $timeProvider, - $this->randomGenerator, - ); + return new UnixTimeGenerator($this->randomGenerator); } /** diff --git a/src/Generator/UnixTimeGenerator.php b/src/Generator/UnixTimeGenerator.php index 1aef869..af94dec 100644 --- a/src/Generator/UnixTimeGenerator.php +++ b/src/Generator/UnixTimeGenerator.php @@ -14,39 +14,156 @@ declare(strict_types=1); namespace Ramsey\Uuid\Generator; -use Ramsey\Uuid\Converter\TimeConverterInterface; -use Ramsey\Uuid\Provider\TimeProviderInterface; +use Brick\Math\BigInteger; +use DateTimeImmutable; +use DateTimeInterface; +use Ramsey\Uuid\Type\Hexadecimal; -use function hex2bin; +use function hash; +use function pack; +use function str_pad; +use function strlen; +use function substr; +use function substr_replace; +use function unpack; + +use const PHP_INT_SIZE; +use const STR_PAD_LEFT; /** * UnixTimeGenerator generates bytes that combine a 48-bit timestamp in * milliseconds since the Unix Epoch with 80 random bits + * + * Code and concepts within this class are borrowed from the symfony/uid package + * and are used under the terms of the MIT license distributed with symfony/uid. + * + * symfony/uid is copyright (c) Fabien Potencier. + * + * @link https://symfony.com/components/Uid Symfony Uid component + * @link https://github.com/symfony/uid/blob/4f9f537e57261519808a7ce1d941490736522bbc/UuidV7.php Symfony UuidV7 class + * @link https://github.com/symfony/uid/blob/6.2/LICENSE MIT License */ class UnixTimeGenerator implements TimeGeneratorInterface { + private static string $time = ''; + private static ?string $seed = null; + private static int $seedIndex = 0; + + /** @var int[] */ + private static array $rand = []; + + /** @var int[] */ + private static array $seedParts; + public function __construct( - private TimeConverterInterface $timeConverter, - private TimeProviderInterface $timeProvider, - private RandomGeneratorInterface $randomGenerator + private RandomGeneratorInterface $randomGenerator, + private int $intSize = PHP_INT_SIZE ) { } /** + * @param Hexadecimal|int|string|null $node Unused in this generator + * @param int|null $clockSeq Unused in this generator + * @param DateTimeInterface $dateTime A date-time instance to use when + * generating bytes + * * @inheritDoc */ - public function generate($node = null, ?int $clockSeq = null): string + public function generate($node = null, ?int $clockSeq = null, ?DateTimeInterface $dateTime = null): string { - // Generate 10 random bytes to append to the string returned, since our - // time bytes will consist of 6 bytes. - $random = $this->randomGenerator->generate(10); + $time = ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); - $time = $this->timeProvider->getTime(); - $unixTimeHex = $this->timeConverter->calculateTime( - $time->getSeconds()->toString(), - $time->getMicroseconds()->toString(), - ); + if ($time > self::$time || ($dateTime !== null && $time !== self::$time)) { + $this->randomize($time); + } else { + $time = $this->increment(); + } - return hex2bin($unixTimeHex->toString()) . $random; + if ($this->intSize >= 8) { + $time = substr(pack('J', (int) $time), -6); + } else { + $time = str_pad(BigInteger::of($time)->toBytes(false), 6, "\x00", STR_PAD_LEFT); + } + + /** @var non-empty-string */ + return $time . pack('n*', self::$rand[1], self::$rand[2], self::$rand[3], self::$rand[4], self::$rand[5]); + } + + private function randomize(string $time): void + { + if (self::$seed === null) { + $seed = $this->randomGenerator->generate(16); + self::$seed = $seed; + } else { + $seed = $this->randomGenerator->generate(10); + } + + /** @var int[] $rand */ + $rand = unpack('n*', $seed); + $rand[1] &= 0x03ff; + + self::$rand = $rand; + self::$time = $time; + } + + /** + * Special thanks to Nicolas Grekas for sharing the following information: + * + * Within the same ms, we increment the rand part by a random 24-bit number. + * + * Instead of getting this number from random_bytes(), which is slow, we get + * it by sha512-hashing self::$seed. This produces 64 bytes of entropy, + * which we need to split in a list of 24-bit numbers. unpack() first splits + * them into 16 x 32-bit numbers; we take the first byte of each of these + * numbers to get 5 extra 24-bit numbers. Then, we consume those numbers + * one-by-one and run this logic every 21 iterations. + * + * self::$rand holds the random part of the UUID, split into 5 x 16-bit + * numbers for x86 portability. We increment this random part by the next + * 24-bit number in the self::$seedParts list and decrement + * self::$seedIndex. + * + * @link https://twitter.com/nicolasgrekas/status/1583356938825261061 Tweet from Nicolas Grekas + */ + private function increment(): string + { + if (self::$seedIndex === 0 && self::$seed !== null) { + self::$seed = hash('sha512', self::$seed, true); + + /** @var int[] $s */ + $s = unpack('l*', self::$seed); + $s[] = ($s[1] >> 8 & 0xff0000) | ($s[2] >> 16 & 0xff00) | ($s[3] >> 24 & 0xff); + $s[] = ($s[4] >> 8 & 0xff0000) | ($s[5] >> 16 & 0xff00) | ($s[6] >> 24 & 0xff); + $s[] = ($s[7] >> 8 & 0xff0000) | ($s[8] >> 16 & 0xff00) | ($s[9] >> 24 & 0xff); + $s[] = ($s[10] >> 8 & 0xff0000) | ($s[11] >> 16 & 0xff00) | ($s[12] >> 24 & 0xff); + $s[] = ($s[13] >> 8 & 0xff0000) | ($s[14] >> 16 & 0xff00) | ($s[15] >> 24 & 0xff); + + self::$seedParts = $s; + self::$seedIndex = 21; + } + + self::$rand[5] = 0xffff & $carry = self::$rand[5] + (self::$seedParts[self::$seedIndex--] & 0xffffff); + self::$rand[4] = 0xffff & $carry = self::$rand[4] + ($carry >> 16); + self::$rand[3] = 0xffff & $carry = self::$rand[3] + ($carry >> 16); + self::$rand[2] = 0xffff & $carry = self::$rand[2] + ($carry >> 16); + self::$rand[1] += $carry >> 16; + + if (0xfc00 & self::$rand[1]) { + $time = self::$time; + $mtime = (int) substr($time, -9); + + if ($this->intSize >= 8 || strlen($time) < 10) { + $time = (string) ((int) $time + 1); + } elseif ($mtime === 999999999) { + $time = (1 + (int) substr($time, 0, -9)) . '000000000'; + } else { + $mtime++; + $time = substr_replace($time, str_pad((string) $mtime, 9, '0', STR_PAD_LEFT), -9); + } + + $this->randomize($time); + } + + return self::$time; } } diff --git a/src/Type/Time.php b/src/Type/Time.php index 745b5cc..0cedb44 100644 --- a/src/Type/Time.php +++ b/src/Type/Time.php @@ -56,7 +56,7 @@ final class Time implements TypeInterface public function toString(): string { - return $this->seconds->toString() . '.' . $this->microseconds->toString(); + return $this->seconds->toString() . '.' . sprintf('%06s', $this->microseconds->toString()); } public function __toString(): string diff --git a/src/UuidFactory.php b/src/UuidFactory.php index ab730f7..d340ca5 100644 --- a/src/UuidFactory.php +++ b/src/UuidFactory.php @@ -18,7 +18,6 @@ use DateTimeInterface; use Ramsey\Uuid\Builder\UuidBuilderInterface; use Ramsey\Uuid\Codec\CodecInterface; use Ramsey\Uuid\Converter\NumberConverterInterface; -use Ramsey\Uuid\Converter\Time\UnixTimeConverter; use Ramsey\Uuid\Converter\TimeConverterInterface; use Ramsey\Uuid\Generator\DceSecurityGeneratorInterface; use Ramsey\Uuid\Generator\DefaultTimeGenerator; @@ -27,7 +26,6 @@ use Ramsey\Uuid\Generator\RandomGeneratorInterface; use Ramsey\Uuid\Generator\TimeGeneratorInterface; use Ramsey\Uuid\Generator\UnixTimeGenerator; use Ramsey\Uuid\Lazy\LazyUuidFromString; -use Ramsey\Uuid\Math\BrickMathCalculator; use Ramsey\Uuid\Provider\NodeProviderInterface; use Ramsey\Uuid\Provider\Time\FixedTimeProvider; use Ramsey\Uuid\Type\Hexadecimal; @@ -396,21 +394,8 @@ class UuidFactory implements UuidFactoryInterface */ public function uuid7(?DateTimeInterface $dateTime = null): UuidInterface { - if ($dateTime !== null) { - $timeProvider = new FixedTimeProvider( - new Time($dateTime->format('U'), $dateTime->format('u')) - ); - - $timeGenerator = new UnixTimeGenerator( - new UnixTimeConverter(new BrickMathCalculator()), - $timeProvider, - $this->randomGenerator, - ); - - $bytes = $timeGenerator->generate(); - } else { - $bytes = $this->unixTimeGenerator->generate(); - } + assert($this->unixTimeGenerator instanceof UnixTimeGenerator); + $bytes = $this->unixTimeGenerator->generate(null, null, $dateTime); return $this->uuidFromBytesAndVersion($bytes, Uuid::UUID_TYPE_UNIX_TIME); } diff --git a/tests/Generator/UnixTimeGeneratorTest.php b/tests/Generator/UnixTimeGeneratorTest.php index 1b7a93d..356f99f 100644 --- a/tests/Generator/UnixTimeGeneratorTest.php +++ b/tests/Generator/UnixTimeGeneratorTest.php @@ -4,36 +4,201 @@ declare(strict_types=1); namespace Ramsey\Uuid\Test\Generator; +use DateTimeImmutable; use Mockery; use Mockery\MockInterface; -use Ramsey\Uuid\Converter\Time\UnixTimeConverter; +use Ramsey\Uuid\Generator\RandomBytesGenerator; use Ramsey\Uuid\Generator\RandomGeneratorInterface; use Ramsey\Uuid\Generator\UnixTimeGenerator; -use Ramsey\Uuid\Math\BrickMathCalculator; -use Ramsey\Uuid\Provider\TimeProviderInterface; use Ramsey\Uuid\Test\TestCase; -use Ramsey\Uuid\Type\Time; class UnixTimeGeneratorTest extends TestCase { + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ public function testGenerate(): void { - $unixTimeConverter = new UnixTimeConverter(new BrickMathCalculator()); - - /** @var TimeProviderInterface&MockInterface $timeProvider */ - $timeProvider = Mockery::mock(TimeProviderInterface::class, [ - 'getTime' => new Time('1578612359', '521023'), - ]); + $dateTime = new DateTimeImmutable('@1578612359.521023'); + $expectedBytes = "\x01\x6f\x8c\xa1\x01\x61\x03\x00\xff\x00\xff\x00\xff\x00\xff\x00"; /** @var RandomGeneratorInterface&MockInterface $randomGenerator */ $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); - $randomGenerator->expects()->generate(10)->andReturns("\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00"); - - $unixTimeGenerator = new UnixTimeGenerator($unixTimeConverter, $timeProvider, $randomGenerator); - - $this->assertSame( - "\x01\x6f\x8c\xa1\x01\x61\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00", - $unixTimeGenerator->generate(), + $randomGenerator->expects()->generate(16)->andReturns( + "\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00", ); + + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator); + + $bytes = $unixTimeGenerator->generate(null, null, $dateTime); + + $this->assertSame($expectedBytes, $bytes); + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResults(): void + { + $randomGenerator = new RandomBytesGenerator(); + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsWithSameDate(): void + { + $dateTime = new DateTimeImmutable('now'); + $randomGenerator = new RandomBytesGenerator(); + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(null, null, $dateTime); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsFor32BitPath(): void + { + $randomGenerator = new RandomBytesGenerator(); + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator, 4); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsWithSameDateFor32BitPath(): void + { + $dateTime = new DateTimeImmutable('now'); + $randomGenerator = new RandomBytesGenerator(); + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator, 4); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(null, null, $dateTime); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsStartingWithAllBitsSet(): void + { + /** @var RandomGeneratorInterface&MockInterface $randomGenerator */ + $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); + $randomGenerator->expects()->generate(16)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + $randomGenerator->expects()->generate(10)->times(24)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsStartingWithAllBitsSetWithSameDate(): void + { + $dateTime = new DateTimeImmutable('now'); + + /** @var RandomGeneratorInterface&MockInterface $randomGenerator */ + $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); + $randomGenerator->expects()->generate(16)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + $randomGenerator->expects()->generate(10)->times(24)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(null, null, $dateTime); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsStartingWithAllBitsSetFor32BitPath(): void + { + /** @var RandomGeneratorInterface&MockInterface $randomGenerator */ + $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); + $randomGenerator->expects()->generate(16)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + $randomGenerator->expects()->generate(10)->times(24)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator, 4); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(); + $this->assertTrue($bytes > $previous); + } + } + + /** + * @runInSeparateProcess since values are stored statically on the class + * @preserveGlobalState disabled + */ + public function testGenerateProducesMonotonicResultsStartingWithAllBitsSetWithSameDateFor32BitPath(): void + { + $dateTime = new DateTimeImmutable('now'); + + /** @var RandomGeneratorInterface&MockInterface $randomGenerator */ + $randomGenerator = Mockery::mock(RandomGeneratorInterface::class); + $randomGenerator->expects()->generate(16)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + $randomGenerator->expects()->generate(10)->times(24)->andReturns( + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ); + + $unixTimeGenerator = new UnixTimeGenerator($randomGenerator, 4); + + $previous = ''; + for ($i = 0; $i < 25; $i++) { + $bytes = $unixTimeGenerator->generate(null, null, $dateTime); + $this->assertTrue($bytes > $previous); + } } } diff --git a/tests/Type/TimeTest.php b/tests/Type/TimeTest.php index 98287e6..62df44f 100644 --- a/tests/Type/TimeTest.php +++ b/tests/Type/TimeTest.php @@ -28,9 +28,9 @@ class TimeTest extends TestCase if ($microseconds !== null) { $params[] = $microseconds; - $timeString .= ".{$microseconds}"; + $timeString .= sprintf('.%06s', (string) $microseconds); } else { - $timeString .= '.0'; + $timeString .= '.000000'; } $time = new Time(...$params); diff --git a/tests/UuidTest.php b/tests/UuidTest.php index 06dbb4d..849b0f3 100644 --- a/tests/UuidTest.php +++ b/tests/UuidTest.php @@ -768,6 +768,65 @@ class UuidTest extends TestCase ); } + public function testUuid7SettingTheClockBackwards(): void + { + $dates = [ + new DateTimeImmutable('now'), + new DateTimeImmutable('last year'), + new DateTimeImmutable('1979-01-01 00:00:00.000000'), + ]; + + foreach ($dates as $dateTime) { + $previous = Uuid::uuid7($dateTime); + for ($i = 0; $i < 25; $i++) { + $uuid = Uuid::uuid7($dateTime); + $this->assertGreaterThan(0, $uuid->compareTo($previous)); + $this->assertSame($dateTime->format('Y-m-d H:i'), $uuid->getDateTime()->format('Y-m-d H:i')); + $previous = $uuid; + } + } + } + + public function testUuid7WithMinimumDateTime(): void + { + $dateTime = new DateTimeImmutable('1979-01-01 00:00:00.000000'); + + $uuid = Uuid::uuid7($dateTime); + $this->assertInstanceOf(DateTimeInterface::class, $uuid->getDateTime()); + $this->assertSame(2, $uuid->getVariant()); + $this->assertSame(7, $uuid->getVersion()); + $this->assertSame( + '1979-01-01T00:00:00.000+00:00', + $uuid->getDateTime()->format(DateTimeInterface::RFC3339_EXTENDED), + ); + } + + public function testUuid7EachUuidIsMonotonicallyIncreasing(): void + { + $previous = Uuid::uuid7(); + + for ($i = 0; $i < 25; $i++) { + $uuid = Uuid::uuid7(); + $now = gmdate('Y-m-d H:i'); + $this->assertGreaterThan(0, $uuid->compareTo($previous)); + $this->assertSame($now, $uuid->getDateTime()->format('Y-m-d H:i')); + $previous = $uuid; + } + } + + public function testUuid7EachUuidFromSameDateTimeIsMonotonicallyIncreasing(): void + { + $dateTime = new DateTimeImmutable(); + $previous = Uuid::uuid7($dateTime); + + for ($i = 0; $i < 25; $i++) { + $uuid = Uuid::uuid7($dateTime); + $this->assertGreaterThan(0, $uuid->compareTo($previous)); + $this->assertSame($dateTime->format('Y-m-d H:i'), $uuid->getDateTime()->format('Y-m-d H:i')); + $previous = $uuid; + } + } + /** * Tests known version-3 UUIDs *