From c95d00a65d125b585bde8c767198414970074c6c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 2 Nov 2026 07:51:09 +0600 Subject: [PATCH] :sparkles: feat(uuid): introduce UUID v2 DCE security support - Add UUID v2 generation, parsing, validation, and helper methods :sparkles: - Update documentation and requirements for Runwire 3.0 support :memo: - Clean up outdated benchmarks and reports :fire: --- .gitattributes | 15 +++ README.md | 4 +- benchmarks/ContentionMatrix.php | 167 -------------------------------- benchmarks/HotspotBench.php | 6 ++ composer.json | 2 +- docs/benchmark-report.rst | 109 --------------------- docs/compatibility.rst | 5 +- docs/framework-integration.rst | 6 +- docs/helpers.rst | 2 +- docs/index.rst | 3 +- docs/installation.rst | 2 +- docs/references.rst | 1 + docs/snowflake.rst | 3 + docs/sonyflake.rst | 3 + docs/uuid.rst | 40 +++++++- src/Id.php | 9 ++ src/Snowflake.php | 11 ++- src/Sonyflake.php | 11 ++- src/UUID.php | 66 ++++++++++--- src/Value/UuidValue.php | 2 +- src/functions.php | 9 ++ tests/ForkSafetyTest.php | 1 + tests/IdFactoryTest.php | 2 + tests/ReleaseBoundaryTest.php | 22 +++++ tests/UUIDTest.php | 80 +++++++++++++++ tests/smoke.php | 3 +- 26 files changed, 273 insertions(+), 311 deletions(-) delete mode 100644 benchmarks/ContentionMatrix.php delete mode 100644 docs/benchmark-report.rst diff --git a/.gitattributes b/.gitattributes index 2755315..6619e7e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,12 +3,27 @@ benchmarks export-ignore docs export-ignore examples export-ignore tests export-ignore +vendor export-ignore +graphify-out export-ignore +phpforge export-ignore +plans export-ignore +var export-ignore + +.agents export-ignore +.aws export-ignore +.codex export-ignore +.idea export-ignore +.vscode export-ignore +.windsurf export-ignore +.psalm-cache export-ignore +.phpunit.cache export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore .readthedocs.yaml export-ignore captainhook.json export-ignore +composer.lock export-ignore pest.xml export-ignore phpbench.json export-ignore phpcs.xml.dist export-ignore diff --git a/README.md b/README.md index 9c1b0a2..c63b669 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ All-in-one unique ID toolkit for PHP. ## Features -- UUID (`v1`, `v3`, `v4`, `v5`, `v6`, `v7`, `v8`) +- UUID (`v1`, `v2`, `v3`, `v4`, `v5`, `v6`, `v7`, `v8`) - ULID (monotonic and random modes) - Snowflake, Sonyflake, Randflake, TBSL - TypeID, ObjectID, NanoID, RandomId, CUID2, KSUID, XID @@ -21,7 +21,7 @@ All-in-one unique ID toolkit for PHP. - Binary conversion and base encoders (`10`, `16`, `32`, `36`, `58`, `62`) - Pluggable sequence providers (filesystem, memory, PSR-16 cache, callback) - Explicit legacy/upstream Sonyflake and Randflake compatibility modes -- Optional PSR-20 clocks and Runwire 2.1.1+ cooperative wait integration +- Optional PSR-20 clocks and Runwire 3.0 cooperative wait integration ## Requirements diff --git a/benchmarks/ContentionMatrix.php b/benchmarks/ContentionMatrix.php deleted file mode 100644 index b6fe2dc..0000000 --- a/benchmarks/ContentionMatrix.php +++ /dev/null @@ -1,167 +0,0 @@ - $processCounts - * @param list $reservationSizes - */ - public static function run( - array $processCounts = [1, 2, 4, 8, 16], - array $reservationSizes = [1, 4, 8, 16, 32, 64], - int $idsPerProcess = 200, - ): void { - self::assertPcntl(); - fwrite(STDOUT, "processes,reservation,ids_per_second,median_us,p95_us,p99_us,duplicates,lock_errors,cpu_ms\n"); - - foreach ($processCounts as $processes) { - foreach ($reservationSizes as $reservationSize) { - fwrite(STDOUT, implode(',', self::measure($processes, $reservationSize, $idsPerProcess)) . "\n"); - } - } - } - - private static function assertPcntl(): void - { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - throw new \RuntimeException('The pcntl extension is required for contention benchmarks'); - } - } - - /** - * @param array $children - * @return array{list,list,int,int} - */ - private static function collect(array $children): array - { - $ids = []; - $latencies = []; - $errors = 0; - $cpuMicros = 0; - - foreach ($children as $pid => $output) { - pcntl_waitpid($pid, $status); - $json = file_get_contents($output); - if (!is_string($json)) { - ++$errors; - - continue; - } - - $result = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - $ids = [...$ids, ...$result['ids']]; - $latencies = [...$latencies, ...$result['latencies']]; - $errors += $result['errors']; - $cpuMicros += $result['cpu_micros']; - } - - return [$ids, $latencies, $errors, $cpuMicros]; - } - - private static function cpuMicros(array $usage): int - { - return (($usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec']) * 1_000_000) - + $usage['ru_utime.tv_usec'] - + $usage['ru_stime.tv_usec']; - } - - /** - * @return array{int,int,string,string,string,string,int,int,string} - */ - private static function measure(int $processes, int $reservationSize, int $idsPerProcess): array - { - $directory = sys_get_temp_dir() . '/uid-contention-' . bin2hex(random_bytes(8)); - mkdir($directory, 0700); - $children = []; - $started = hrtime(true); - - try { - for ($process = 0; $process < $processes; ++$process) { - $output = $directory . '/child-' . $process . '.json'; - $pid = pcntl_fork(); - if ($pid === -1) { - throw new \RuntimeException('Unable to fork benchmark process'); - } - - if ($pid === 0) { - self::runChild($directory, $output, $reservationSize, $idsPerProcess); - } - - $children[$pid] = $output; - } - - [$ids, $latencies, $errors, $cpuMicros] = self::collect($children); - $elapsedSeconds = (hrtime(true) - $started) / 1_000_000_000; - sort($latencies, SORT_NUMERIC); - - return [ - $processes, - $reservationSize, - number_format(count($ids) / $elapsedSeconds, 0, '.', ''), - number_format(self::percentile($latencies, 0.50), 2, '.', ''), - number_format(self::percentile($latencies, 0.95), 2, '.', ''), - number_format(self::percentile($latencies, 0.99), 2, '.', ''), - count($ids) - count(array_unique($ids)), - $errors, - number_format($cpuMicros / 1000, 2, '.', ''), - ]; - } finally { - foreach (glob($directory . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($directory); - } - } - - private static function percentile(array $sorted, float $percentile): float - { - if ($sorted === []) { - return 0.0; - } - - $index = (int) ceil(count($sorted) * $percentile) - 1; - - return $sorted[max(0, $index)]; - } - - private static function runChild( - string $directory, - string $output, - int $reservationSize, - int $idsPerProcess, - ): never { - $provider = new FilesystemSequenceProvider($directory, 'matrix', reservationSize: $reservationSize); - $usageBefore = getrusage(); - $ids = []; - $latencies = []; - $errors = 0; - - for ($index = 0; $index < $idsPerProcess; ++$index) { - $started = hrtime(true); - - try { - $ids[] = $provider->next('sequence', 1, 123456789); - } catch (\Throwable) { - ++$errors; - } - $latencies[] = (hrtime(true) - $started) / 1000; - } - - $usageAfter = getrusage(); - file_put_contents($output, json_encode([ - 'ids' => $ids, - 'latencies' => $latencies, - 'errors' => $errors, - 'cpu_micros' => self::cpuMicros($usageAfter) - self::cpuMicros($usageBefore), - ], JSON_THROW_ON_ERROR)); - pcntl_exec(PHP_BINARY, ['-r', '']); - - throw new \RuntimeException('Unable to terminate benchmark child'); - } -} diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php index 9f0b5b9..a5a601d 100644 --- a/benchmarks/HotspotBench.php +++ b/benchmarks/HotspotBench.php @@ -199,6 +199,12 @@ public function benchUlidRandom(): void ULID::generateRandom(); } + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchUuidV2(): void + { + UUID::v2(1001); + } + #[Bench\Revs(1000), Bench\Iterations(5)] public function benchUuidV4(): void { diff --git a/composer.json b/composer.json index da0c90f..a394f6f 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "infocyph/runwire": "^2.1.1", + "infocyph/runwire": "^3.0", "psr/clock": "^1.0", "psr/simple-cache": "^3.0" }, diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst deleted file mode 100644 index af57ab5..0000000 --- a/docs/benchmark-report.rst +++ /dev/null @@ -1,109 +0,0 @@ -v5 Benchmark Report -=================== - -Recorded on 2026-08-15 with PHP 8.4.24, PHPBench 1.7.0, Xdebug disabled, -and OPcache disabled. Each result below is the mode of five iterations. -Generator methods use 1000 revolutions; filesystem methods use 250 or 500. - -========================== ========== -Subject Mode -========================== ========== -UUID v4 1.782 us -UUID v7 2.067 us -ULID random 3.726 us -ULID monotonic 2.203 us -TypeID 17.063 us -ObjectID 0.662 us -NanoID 0.777 us -CUID2 178.307 us -RandomId 4.358 us -KSUID 18.894 us -XID 10.094 us -Snowflake filesystem 15.606 us -Sonyflake filesystem 16.515 us -Randflake filesystem 25.242 us -TBSL filesystem 15.823 us -DeterministicId 2.831 us -OpaqueId encode/decode 2.337 / 1.683 us -========================== ========== - -Provider Isolation ------------------- - -========================== ========== -Subject Mode -========================== ========== -Snowflake filesystem 15.562 us -Snowflake in-memory 2.355 us -Randflake filesystem 24.493 us -Randflake in-memory 11.628 us -========================== ========== - -Native Codec, 16 Bytes ----------------------- - -The checked-in codec harness covers 8, 10, 12, 16, 20, and 32-byte inputs for -each base. The 16-byte slice is shown here for a compact release baseline. - -======== ========== -Base Mode -======== ========== -16 0.287 us -32 15.113 us -36 14.452 us -58 13.079 us -62 13.026 us -decimal 22.236 us -======== ========== - -The former generation harness constructed closures and dispatch arrays inside -the measured operation, and the former sequence harness constructed providers -inside it. Those measurements are not comparable algorithm baselines, so no -misleading percentage delta is reported. This report is the first isolated v5 -baseline; future releases should compare against it using the checked-in harness. - -Current Release Checks ----------------------- - -Release acceptance runs one 100-cycle smoke pass across every generator and -supported format on PHP 8.4 and 8.5, using a production-only Composer install. -It checks valid output, no repeated IDs within each sample, relevant binary -round trips, configured node fields and deterministic results. Run it locally -with ``php tests/smoke.php``. - -The Security & Standards workflow retains the full test suite, static and taint -analysis, dependency audit, and process/fork, cancellation, exhaustion and -malformed-input regressions. The smoke pass is an additional consumer check; -it does not establish collision probability, cryptographic security or sustained -host throughput. - -The former long HTTP performance matrix and persistent-worker soak are removed. -There is no mandatory RPM comparison against tag ``5.0``. Existing component -benchmark tools remain available for targeted investigations; the historical -v5 measurements above are not v6 performance claims. - -Filesystem Contention ---------------------- - -``ContentionMatrix`` ran 200 allocations per process for 1, 2, 4, 8, and 16 -processes and reservation sizes 1, 4, 8, 16, 32, and 64. All 30 cases produced -zero duplicates and zero lock errors. Representative endpoints are: - -========= =========== ======== ========== ======== ======== ====== -Processes Reservation IDs/sec Median us p95 us p99 us CPU ms -========= =========== ======== ========== ======== ======== ====== -1 1 3,697 10.23 16.40 23.21 2.48 -1 64 3,865 0.59 0.84 13.34 0.39 -2 1 8,916 16.97 27.32 53.38 7.81 -2 64 10,012 0.58 0.74 13.69 0.74 -4 1 19,639 10.65 25.57 53.91 10.53 -4 64 21,036 0.58 0.95 15.12 1.53 -8 1 22,890 20.14 124.93 249.60 34.35 -8 64 25,630 1.02 1.27 23.97 3.87 -16 1 23,094 17.48 152.58 1,688.23 66.50 -16 64 26,819 1.07 1.37 23.07 8.40 -========= =========== ======== ========== ======== ======== ====== - -These host-specific numbers are diagnostic rather than universal. Reservation -size 1 remains the correctness-first default; larger reservations trade unused -allocations on process exit for substantially less lock and CPU pressure. diff --git a/docs/compatibility.rst b/docs/compatibility.rst index c3a8b84..c180324 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -5,6 +5,7 @@ Format Compatibility -------------------- - UUID v1/v3/v4/v5 follows RFC 4122-compatible layouts; UUID v6/v7 follows RFC 9562. +- UUID v2 uses the DCE Security layout, including local identifier and domain fields. - UUID v8 uses an application-defined payload. Generic parsing does not infer a timestamp. - TypeID implements the TypeID v0.3 text format over 16 UUID bytes. - ObjectID implements the BSON 12-byte ObjectID layout and canonical lowercase hexadecimal text. @@ -26,6 +27,7 @@ Format Uniqueness model ===================== ============================================================== UUID v4 Probabilistic cryptographic randomness UUID v7 Randomness plus process-local monotonic generation +UUID v2 Random nodes by default; caller coordination for fixed nodes ULID Randomness plus optional process-local monotonic generation TypeID Inherits the encoded UUID's uniqueness properties ObjectID Timestamp, process-random value, and process-local counter @@ -54,6 +56,7 @@ UUID v4/v7 Identifiers, not authorization secrets Snowflake/Sonyflake Structured values whose metadata may be inferred TBSL Structured value whose timestamp is recoverable ObjectID/XID/KSUID Timestamp metadata is recoverable +UUID v2 Local identity and coarse timestamp are recoverable Randflake Obscures fields; it is not authenticated encryption OpaqueId Reversible obfuscation only DeterministicId Stable deterministic token; it does not provide secrecy @@ -66,6 +69,6 @@ Runtime Requirements - PHP 8.4 or newer on a 64-bit runtime. - The ctype extension is required. -- Runwire 2.1.1+ and PSR-20 clocks are optional passed-instance integrations. +- Runwire 3.0 and PSR-20 clocks are optional passed-instance integrations. - No BCMath dependency. - PSR-16 is optional and needed only for the PSR simple-cache sequence provider. diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index 5f930b2..f200221 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -22,7 +22,7 @@ application domain that owns their node/machine ID, epoch, sequence provider and runtime policy. SnowflakeConfig, SonyflakeConfig, RandflakeConfig and TBSLConfig may receive a GenerationContext without changing the ordinary synchronous APIs. -Runwire 2.1.1+ Integration +Runwire 3.0 Integration -------------------------- Runwire support is optional. UID never discovers a runtime globally and never @@ -58,8 +58,8 @@ operation. Construct bindings after worker creation/fork and do not retain a request binding in static provider selectors or worker-wide mutable state. The binding validates the process, request/runtime identity, request completion, active coroutine -capability, cancellation and deadlines using Runwire 2.1.1 public APIs. UID's -development fixture targets ``infocyph/runwire ^2.1.1``. +capability, cancellation and deadlines using Runwire 3.0 public APIs. UID's +development fixture targets ``infocyph/runwire ^3.0``. A CoroutineScope does not expose public request identity. Therefore the host boundary that owns both objects is responsible for pairing the correct scope with diff --git a/docs/helpers.rst b/docs/helpers.rst index 4bee3e8..0e67793 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -18,7 +18,7 @@ Import them explicitly before use: $objectId = object_id(); $random = random_id(24); -Available helpers are ``uuid1``, ``uuid3``, ``uuid4``, ``uuid5``, ``uuid6``, +Available helpers are ``uuid1``, ``uuid2``, ``uuid3``, ``uuid4``, ``uuid5``, ``uuid6``, ``uuid7``, ``uuid8``, ``ulid``, ``type_id``, ``object_id``, ``snowflake``, ``sonyflake``, ``randflake``, ``tbsl``, ``ksuid``, ``xid``, ``nano_id``, ``cuid2``, and ``random_id``. diff --git a/docs/index.rst b/docs/index.rst index 69e2b6d..ee16be8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,7 @@ UID is an all-in-one ID toolkit for PHP with generation, parsing, validation, an It supports: -- UUID (v1, v3, v4, v5, v6, v7, v8) +- UUID (v1, v2, v3, v4, v5, v6, v7, v8) - ULID - TypeID - ObjectID @@ -51,7 +51,6 @@ It supports: helpers db-storage compatibility - benchmark-report framework-integration exceptions references diff --git a/docs/installation.rst b/docs/installation.rst index 2b698ad..496f452 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -30,7 +30,7 @@ only the integration used by the application: .. code-block:: bash - composer require infocyph/runwire:^2.1.1 + composer require infocyph/runwire:^3.0 composer require psr/clock:^1.0 composer require psr/simple-cache:^3.0 diff --git a/docs/references.rst b/docs/references.rst index f8ee1bf..429e5f0 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -5,6 +5,7 @@ Standards and Specs ------------------- - UUID RFC 9562: https://datatracker.ietf.org/doc/html/rfc9562 +- DCE UUID v2 interoperability guide and example vector: https://uuid.ramsey.dev/en/stable/rfc4122/version2.html - ULID spec: https://github.com/ulid/spec - Twitter Snowflake (archived): https://github.com/twitter-archive/snowflake/tree/snowflake-2010 - Sonyflake: https://github.com/sony/sonyflake diff --git a/docs/snowflake.rst b/docs/snowflake.rst index 1f32976..2112d4d 100644 --- a/docs/snowflake.rst +++ b/docs/snowflake.rst @@ -79,6 +79,9 @@ Custom Epoch APIs The epoch is immutable global-domain configuration: supply it through a config and retain it when parsing. Changing an epoch creates a different ID domain. +Parsing supports negative epoch milliseconds, including fractional seconds before +1970. An epoch plus encoded offset that exceeds PHP's integer range throws +``SnowflakeException``. Binary and Alternate Bases -------------------------- diff --git a/docs/sonyflake.rst b/docs/sonyflake.rst index 17951ec..d152c1c 100644 --- a/docs/sonyflake.rst +++ b/docs/sonyflake.rst @@ -81,6 +81,9 @@ The epoch is immutable domain configuration: supply it through a config and retain both epoch and format metadata when parsing. The upstream mode uses its upstream default epoch unless a custom epoch is explicitly configured. Changing format or epoch creates a different ID domain. +Parsing supports negative epoch milliseconds, including fractional seconds before +1970. An epoch plus encoded offset that exceeds PHP's integer range throws +``SonyflakeException``. Binary and Alternate Bases -------------------------- diff --git a/docs/uuid.rst b/docs/uuid.rst index 755ade1..c6757e9 100644 --- a/docs/uuid.rst +++ b/docs/uuid.rst @@ -7,6 +7,7 @@ Supported Versions ------------------ - ``v1``: time-based +- ``v2``: DCE Security with an embedded local identifier and domain - ``v3``: namespace + MD5 - ``v4``: random - ``v5``: namespace + SHA-1 @@ -25,6 +26,7 @@ Generation use Infocyph\UID\UUID; $v1 = UUID::v1(); + $v2 = UUID::v2(localIdentifier: 1001); $v3 = UUID::v3('url', 'https://example.com'); $v4 = UUID::v4(); $v5 = UUID::v5('dns', 'example.com'); @@ -33,11 +35,41 @@ Generation $v7AtTime = UUID::v7(new DateTimeImmutable('2026-01-01 00:00:00')); $v8 = UUID::v8(); +DCE Security (v2) +----------------- + +``UUID::v2($localIdentifier, $localDomain, $node, $clockSequence)`` requires an +unsigned 32-bit local identifier (``0..4294967295``). The domain defaults to +``UUID::DCE_DOMAIN_PERSON`` (``0``); ``DCE_DOMAIN_GROUP`` (``1``) and +``DCE_DOMAIN_ORG`` (``2``) are also supported. UID does not discover account IDs. + +A null node generates a fresh random multicast node for each call. A null clock +sequence selects a random value in ``0..63``. Explicit nodes require an explicit +clock sequence. For fixed nodes, the caller must coordinate sequences across +processes, forks and restarts: reuse of a node/domain/identifier/sequence within +one approximately 429.5-second timestamp bucket repeats the UUID. Only 64 +sequences fit in each bucket. Default random nodes provide probabilistic +uniqueness; enforce storage uniqueness where required. + +``UUID::parse()`` adds integer ``local_domain`` and ``local_identifier`` fields +for v2 only. Its ``time`` is the beginning of the coarse timestamp bucket, +not the exact creation time. The same behavior is exposed by ``UuidValue``. + +.. code-block:: php + + $person = UUID::v2(1001); + $group = UUID::v2(100, UUID::DCE_DOMAIN_GROUP); + $coordinated = UUID::v2(1001, UUID::DCE_DOMAIN_PERSON, 'abcdef123456', 42); + +The facade ``Id::uuid2()`` and namespaced ``uuid2()`` helper accept the same +arguments. See the `DCE UUID v2 guide `_. + Node-Aware Versions ------------------- -Versions ``v1``, ``v6``, and ``v8`` accept an optional node. If omitted, UID -generates one. UUIDv7 is timestamp/randomness based and does not accept a node. +Versions ``v1``, ``v2``, ``v6``, and ``v8`` accept an optional node. If omitted, +UID generates one. For v2, a fixed node also requires an explicit clock sequence. +UUIDv7 is timestamp/randomness based and does not accept a node. .. code-block:: php @@ -95,6 +127,10 @@ millisecond field instead of truncating them. For ``v7`` and ``v8``, ``node`` is intentionally ``null``. +Versions ``v1`` and ``v6`` preserve timestamps before the Unix epoch, including +fractional seconds. Their 100-nanosecond fields are truncated to PHP's microsecond +precision before conversion. + Binary and Alternate Bases -------------------------- diff --git a/src/Id.php b/src/Id.php index e6225d4..4ca6b51 100644 --- a/src/Id.php +++ b/src/Id.php @@ -109,6 +109,15 @@ public static function uuid1(?string $node = null): string return UUID::v1($node); } + public static function uuid2( + int $localIdentifier, + int $localDomain = UUID::DCE_DOMAIN_PERSON, + ?string $node = null, + ?int $clockSequence = null, + ): string { + return UUID::v2($localIdentifier, $localDomain, $node, $clockSequence); + } + public static function uuid3(string $namespace, string $string): string { return UUID::v3($namespace, $string); diff --git a/src/Snowflake.php b/src/Snowflake.php index 0a17b0d..b9c8005 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -136,7 +136,11 @@ public static function parseWithEpoch(string $id, int $startTimestamp): array } $numericId = (int) $id; - $timestamp = ($numericId >> 22) + $startTimestamp; + $elapsed = $numericId >> 22; + if ($startTimestamp > PHP_INT_MAX - $elapsed) { + throw new SnowflakeException('Parsed Snowflake timestamp exceeds the supported integer domain'); + } + $timestamp = $elapsed + $startTimestamp; [$seconds, $fraction] = self::timestampParts($timestamp); return [ @@ -409,7 +413,10 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov */ private static function timestampParts(int $timestamp): array { - return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; + return [ + ($timestamp < 0 ? '-' : '') . abs(intdiv($timestamp, 1000)), + (string) (abs($timestamp % 1000) * 1000), + ]; } private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int diff --git a/src/Sonyflake.php b/src/Sonyflake.php index b269cef..e4f5f2a 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -299,12 +299,15 @@ private static function extractParts( SonyflakeFormat $format, ): array { $numericId = (int) $id; - $elapsed = $numericId >> 24; - $timestamp = $startTimestamp + ($elapsed * 10); + $elapsed = ($numericId >> 24) * 10; + if ($startTimestamp > PHP_INT_MAX - $elapsed) { + throw new SonyflakeException('Parsed Sonyflake timestamp exceeds the supported integer domain'); + } + $timestamp = $startTimestamp + $elapsed; return [ - 'seconds' => (string) intdiv($timestamp, 1000), - 'fraction' => (string) (($timestamp % 1000) * 1000), + 'seconds' => ($timestamp < 0 ? '-' : '') . abs(intdiv($timestamp, 1000)), + 'fraction' => (string) (abs($timestamp % 1000) * 1000), 'sequence' => $format === SonyflakeFormat::UPSTREAM ? ($numericId >> 16) & 0xff : $numericId & 0xff, diff --git a/src/UUID.php b/src/UUID.php index 4fef6e7..0af8702 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -14,6 +14,12 @@ final class UUID { + public const int DCE_DOMAIN_GROUP = 1; + + public const int DCE_DOMAIN_ORG = 2; + + public const int DCE_DOMAIN_PERSON = 0; + private const int MAX_V7_TIMESTAMP = 281_474_976_710_655; private const array NS_LIST = [ @@ -221,7 +227,7 @@ public static function normalize(string $uuid): string * Parses a UUID string and returns an array with information about the UUID. * * @param string $uuid The UUID string to parse. - * @return array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} + * @return array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null, local_domain?: int, local_identifier?: int} * @throws Exception */ public static function parse(string $uuid): array @@ -247,18 +253,16 @@ public static function parse(string $uuid): array if (count($uuidData) !== 5) { return $data; } - $variantN = hexdec($uuidData[3][0]); $data['version'] = (int) $uuidData[2][0]; - $data['time'] = in_array($data['version'], [1, 6, 7], true) ? self::getTime($uuidData, $data['version']) : null; + $data['time'] = in_array($data['version'], [1, 2, 6, 7], true) ? self::getTime($uuidData, $data['version']) : null; $data['tail'] = $uuidData[4]; $data['node'] = in_array($data['version'], [7, 8], true) ? null : $uuidData[4]; - $data['variant'] = match (true) { - $variantN <= 7 => 'NCS', - $variantN >= 8 && $variantN <= 11 => 'DCE 1.1, ISO/IEC 11578:1996', - $variantN === 12 || $variantN === 13 => 'Microsoft GUID', - $variantN === 14 => 'Reserved', - default => 'Unknown', - }; + $data['variant'] = 'DCE 1.1, ISO/IEC 11578:1996'; + + if ($data['version'] === 2) { + $data['local_domain'] = (int) hexdec(substr($uuidData[3], 2, 2)); + $data['local_identifier'] = (int) hexdec($uuidData[0]); + } return $data; } @@ -342,6 +346,36 @@ public static function v1(?string $node = null): string ); } + /** + * Encodes a DCE identity; fixed nodes require a caller-coordinated six-bit sequence. + * + * @throws UUIDException|Exception + */ + public static function v2( + int $localIdentifier, + int $localDomain = self::DCE_DOMAIN_PERSON, + ?string $node = null, + ?int $clockSequence = null, + ): string { + if ($localIdentifier < 0 || $localIdentifier > 0xffffffff) { + throw new UUIDException('UUID v2 local identifier must be between 0 and 4294967295'); + } + if ($localDomain < self::DCE_DOMAIN_PERSON || $localDomain > self::DCE_DOMAIN_ORG) { + throw new UUIDException('UUID v2 local domain must be person (0), group (1), or organization (2)'); + } + if ($clockSequence !== null && ($clockSequence < 0 || $clockSequence > 63)) { + throw new UUIDException('UUID v2 clock sequence must be between 0 and 63'); + } + if ($node !== null && $clockSequence === null) { + throw new UUIDException('Explicit UUID v2 nodes require a caller-coordinated clock sequence'); + } + + $uuid = substr_replace(self::v1($node), sprintf('%08x', $localIdentifier), 0, 8); + $uuid[14] = '2'; + + return substr_replace($uuid, sprintf('%02x%02x', 0x80 | ($clockSequence ?? random_int(0, 63)), $localDomain), 19, 4); + } + /** * Generates the v3 UUID for a given string using the specified namespace. * @@ -476,9 +510,10 @@ private static function getTime(array $uuid, int $version): DateTimeInterface { $timestamp = match ($version) { 1 => substr((string) $uuid[2], -3) . $uuid[1] . $uuid[0], + 2 => substr((string) $uuid[2], -3) . $uuid[1] . '00000000', 6 => $uuid[0] . $uuid[1] . substr((string) $uuid[2], -3), 7 => $uuid[0] . $uuid[1], - default => throw new UUIDException('Invalid version (applicable: 1, 6, 7)'), + default => throw new UUIDException('Invalid version (applicable: 1, 2, 6, 7)'), }; switch ($version) { @@ -491,11 +526,14 @@ private static function getTime(array $uuid, int $version): DateTimeInterface break; default: - $unixIntervals = (int) hexdec($timestamp) - self::TIME_OFFSET; + // Truncate Gregorian ticks before subtracting the Unix epoch so + // pre-epoch values retain the correct microsecond boundary. + $microseconds = intdiv((int) hexdec($timestamp), 10) - intdiv(self::TIME_OFFSET, 10); + $magnitude = abs($microseconds); $time = [ - (string) intdiv($unixIntervals, self::SECOND_INTERVALS), + ($microseconds < 0 ? '-' : '') . intdiv($magnitude, 1_000_000), str_pad( - (string) intdiv($unixIntervals % self::SECOND_INTERVALS, 10), + (string) ($magnitude % 1_000_000), 6, '0', STR_PAD_LEFT, diff --git a/src/Value/UuidValue.php b/src/Value/UuidValue.php index 7b383c6..9e8e588 100644 --- a/src/Value/UuidValue.php +++ b/src/Value/UuidValue.php @@ -13,7 +13,7 @@ final readonly class UuidValue implements IdValueInterface { /** - * @var array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null} + * @var array{version: int|null, variant: string|null, time: DateTimeInterface|null, node: string|null, tail: string|null, local_domain?: int, local_identifier?: int} */ private array $parsed; diff --git a/src/functions.php b/src/functions.php index 69ca20e..f753dbc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -73,6 +73,15 @@ function uuid1(?string $node = null): string return UUID::v1($node); } +function uuid2( + int $localIdentifier, + int $localDomain = UUID::DCE_DOMAIN_PERSON, + ?string $node = null, + ?int $clockSequence = null, +): string { + return UUID::v2($localIdentifier, $localDomain, $node, $clockSequence); +} + function uuid3(string $namespace, string $string): string { return UUID::v3($namespace, $string); diff --git a/tests/ForkSafetyTest.php b/tests/ForkSafetyTest.php index d4b5f4f..515625c 100644 --- a/tests/ForkSafetyTest.php +++ b/tests/ForkSafetyTest.php @@ -32,6 +32,7 @@ ->and($parentId)->not()->toBe($childId); })->with([ 'uuid-v1' => [fn(): string => UUID::v1()], + 'uuid-v2' => [fn(): string => UUID::v2(1001)], 'uuid-v7' => [fn(): string => UUID::v7(new DateTimeImmutable('@1700000000.123'))], 'ulid' => [fn(): string => ULID::generateMonotonic(new DateTimeImmutable('@1700000000.123'))], 'cuid2' => [fn(): string => CUID2::generate()], diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php index 7525d7b..8f10924 100644 --- a/tests/IdFactoryTest.php +++ b/tests/IdFactoryTest.php @@ -18,6 +18,7 @@ $typeId = Id::typeId('user'); $objectId = Id::objectId(); $uuid1 = Id::uuid1(); + $uuid2 = Id::uuid2(1001); $namespace = Id::uuid4(); $uuid3 = Id::uuid3($namespace, 'id-factory'); $uuid4 = Id::uuid4(); @@ -42,6 +43,7 @@ ->and($typeId)->toStartWith('user_')->toHaveLength(31) ->and($objectId)->toHaveLength(24) ->and($uuid1)->toBeString()->toHaveLength(36) + ->and($uuid2)->toBeString()->toHaveLength(36) ->and($uuid3)->toBeString()->toHaveLength(36) ->and($uuid4)->toBeString()->toHaveLength(36) ->and($uuid5)->toBeString()->toHaveLength(36) diff --git a/tests/ReleaseBoundaryTest.php b/tests/ReleaseBoundaryTest.php index 868ff76..93ade46 100644 --- a/tests/ReleaseBoundaryTest.php +++ b/tests/ReleaseBoundaryTest.php @@ -180,3 +180,25 @@ function (string $type, int $machineId, int $timestamp) use (&$nested, &$config, expect(fn(): string => Sonyflake::generateWithConfig($config)) ->toThrow(SonyflakeException::class, 'maximum life cycle'); }); + +test('custom epoch parsers preserve negative fractional timestamps', function (int $epoch, string $expected): void { + expect(\Infocyph\UID\Snowflake::parseWithEpoch('0', $epoch)['time']->format('Y-m-d H:i:s.u'))->toBe($expected); + foreach ([SonyflakeFormat::UID, SonyflakeFormat::UPSTREAM] as $format) { + expect(Sonyflake::parseWithEpoch('0', $epoch, $format)['time']->format('Y-m-d H:i:s.u'))->toBe($expected); + } +})->with([ + [-1500, '1969-12-31 23:59:58.500000'], + [-500, '1969-12-31 23:59:59.500000'], + [-1, '1969-12-31 23:59:59.999000'], + [0, '1970-01-01 00:00:00.000000'], + [500, '1970-01-01 00:00:00.500000'], +]); + +test('custom epoch parsers reject timestamp overflow with domain exceptions', function (): void { + expect(fn(): array => \Infocyph\UID\Snowflake::parseWithEpoch((string) PHP_INT_MAX, PHP_INT_MAX)) + ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class); + foreach ([SonyflakeFormat::UID, SonyflakeFormat::UPSTREAM] as $format) { + expect(fn(): array => Sonyflake::parseWithEpoch((string) PHP_INT_MAX, PHP_INT_MAX, $format)) + ->toThrow(SonyflakeException::class); + } +}); diff --git a/tests/UUIDTest.php b/tests/UUIDTest.php index ae7a2cd..6b02831 100644 --- a/tests/UUIDTest.php +++ b/tests/UUIDTest.php @@ -5,6 +5,7 @@ use Infocyph\UID\UUID; use function Infocyph\UID\uuid7; +use function Infocyph\UID\uuid2; test('UUID v1', function () { $startedAt = time() - 1; @@ -18,6 +19,66 @@ ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); +test('UUID v2 encodes local identity and coordinated node fields', function (int $domain, int $identifier, int $sequence): void { + $startedAt = time(); + $uuid = UUID::v2($identifier, $domain, 'ABCDEF123456', $sequence); + $parsed = UUID::parse($uuid); + + expect(UUID::isValid($uuid))->toBeTrue() + ->and(substr($uuid, 0, 8))->toBe(sprintf('%08x', $identifier)) + ->and(substr($uuid, 19, 4))->toBe(sprintf('%02x%02x', 0x80 | $sequence, $domain)) + ->and($parsed['version'])->toBe(2) + ->and($parsed['local_domain'])->toBe($domain) + ->and($parsed['local_identifier'])->toBe($identifier) + ->and($parsed['node'])->toBe('abcdef123456') + ->and($parsed['time']?->getTimestamp())->toBeBetween($startedAt - 430, time()) + ->and(UUID::fromBytes(UUID::toBytes($uuid)))->toBe($uuid) + ->and(UUID::fromBase(UUID::toBase($uuid, 58), 58))->toBe($uuid); +})->with([0, 1, 2])->with([0, 0xffffffff])->with([0, 63]); + +test('UUID v2 parses an independent DCE vector without treating the local ID as time', function (): void { + $uuid = '000001f5-5e9a-21ea-9e00-0242ac130003'; + $parsed = UUID::parse($uuid); + $value = new \Infocyph\UID\Value\UuidValue($uuid); + + expect($parsed['local_identifier'])->toBe(501) + ->and($parsed['local_domain'])->toBe(0) + ->and($parsed['time']?->format('Y-m-d H:i:s.u'))->toBe('2020-03-05 04:30:10.619596') + ->and($value->getTimestamp()?->format('Y-m-d H:i:s.u'))->toBe('2020-03-05 04:30:10.619596') + ->and($value->getVersion())->toBe(2) + ->and($value->isSortable())->toBeFalse(); +}); + +test('UUID v2 uses fresh random nodes and exposes matching convenience APIs', function (): void { + $ids = []; + foreach ([UUID::v2(...), \Infocyph\UID\Id::uuid2(...), uuid2(...)] as $generate) { + for ($index = 0; $index < 100; ++$index) { + $uuid = $generate(1001); + $parsed = UUID::parse($uuid); + expect($parsed['local_identifier'])->toBe(1001) + ->and($parsed['local_domain'])->toBe(0) + ->and(hexdec(substr($parsed['node'], 0, 2)) & 1)->toBe(1); + $ids[] = $uuid; + } + } + expect(array_unique($ids))->toHaveCount(300); +}); + +test('UUID v2 rejects invalid fields and uncoordinated fixed nodes', function (array $arguments): void { + expect(fn(): string => UUID::v2(...$arguments))->toThrow(\Infocyph\UID\Exceptions\UUIDException::class); +})->with([ + [[-1]], + [[0x100000000]], + [[1, -1]], + [[1, 3]], + [[1, 256]], + [[1, 0, null, -1]], + [[1, 0, null, 64]], + [[1, 0, 'abcdef123456']], + [[1, 0, "abcdef123456\n", 0]], + [[1, 0, 'invalid-node', 0]], +]); + $ns = UUID::v4(); test('UUID v3', function () use ($ns) { @@ -59,6 +120,25 @@ ->and($parsed['time']->getTimestamp())->toBeBetween($startedAt, $finishedAt); }); +test('UUID time parsing preserves fractional timestamps across the Unix epoch', function (int $version, int $offset, string $expected): void { + $ticks = str_pad(dechex(0x01b21dd213814000 + $offset), 15, '0', STR_PAD_LEFT); + $uuid = $version === 1 + ? substr($ticks, -8) . '-' . substr($ticks, -12, 4) . '-1' . substr($ticks, 0, 3) . '-8000-000000000000' + : substr($ticks, 0, 8) . '-' . substr($ticks, 8, 4) . '-6' . substr($ticks, 12, 3) . '-8000-000000000000'; + + expect(UUID::isValid($uuid))->toBeTrue() + ->and(UUID::parse($uuid)['time']?->format('Y-m-d H:i:s.u'))->toBe($expected) + ->and((new \Infocyph\UID\Value\UuidValue($uuid))->getTimestamp()?->format('Y-m-d H:i:s.u'))->toBe($expected); +})->with([1, 6])->with([ + [-122_192_928_000_000_000, '1582-10-15 00:00:00.000000'], + [-15_000_000, '1969-12-31 23:59:58.500000'], + [-5_000_000, '1969-12-31 23:59:59.500000'], + [-10, '1969-12-31 23:59:59.999999'], + [-1, '1969-12-31 23:59:59.999999'], + [0, '1970-01-01 00:00:00.000000'], + [5_000_000, '1970-01-01 00:00:00.500000'], +]); + test('UUID v7', function () { $startedAt = time() - 1; $uid = UUID::v7(); diff --git a/tests/smoke.php b/tests/smoke.php index 844ccda..cad384e 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -69,9 +69,10 @@ function uidSmokeId(string $algorithm, string $id, bool $valid, array &$seen): v try { for ($cycle = 0; $cycle < 100; ++$cycle) { - foreach ([1, 3, 4, 5, 6, 7, 8] as $version) { + foreach ([1, 2, 3, 4, 5, 6, 7, 8] as $version) { $id = match ($version) { 1 => UUID::v1(), + 2 => UUID::v2(1001), 3 => UUID::v3('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle), 4 => UUID::v4(), 5 => UUID::v5('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle),