diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 2ed7565..e750ed3 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -19,10 +19,22 @@ jobs: php_versions: '["8.2","8.3","8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' php_extensions: "bcmath" - coverage: "xdebug" composer_flags: "" phpstan_memory_limit: "1G" psalm_threads: "1" run_analysis: true run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" artifact_retention_days: 61 + diff --git a/README.md b/README.md index e924753..5437fb2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ All-in-one unique ID toolkit for PHP. - UUID (`v1`, `v3`, `v4`, `v5`, `v6`, `v7`, `v8`) - ULID (monotonic and random modes) - Snowflake, Sonyflake, TBSL +- Randflake (encrypted 64-bit IDs with lease-bound node windows) - NanoID, CUID2, KSUID, XID - Opaque and deterministic IDs - Value objects and comparator utilities @@ -48,6 +49,12 @@ $ulid = Id::ulid(); $snowflake = Id::snowflake(); $sonyflake = Id::sonyflake(); $tbsl = Id::tbsl(); +$randflake = \Infocyph\UID\Randflake::generate( + nodeId: 42, + leaseStart: time() - 5, + leaseEnd: time() + 300, + secret: 'super-secret-key', +); $nanoid = NanoID::generate(21); $cuid2 = CUID2::generate(24); ``` @@ -77,6 +84,7 @@ The shared byte-level encoder is available as - ULID: https://github.com/ulid/spec - Snowflake: https://github.com/twitter-archive/snowflake/tree/snowflake-2010 - Sonyflake: https://github.com/sony/sonyflake +- Randflake: https://github.com/gosuda/randflake - NanoID: https://github.com/ai/nanoid - CUID2: https://github.com/paralleldrive/cuid2 - TBSL note: https://github.com/infocyph/UID/blob/main/TBSL.md diff --git a/benchmarks/BenchBootstrap.php b/benchmarks/BenchBootstrap.php new file mode 100644 index 0000000..1cf8c5e --- /dev/null +++ b/benchmarks/BenchBootstrap.php @@ -0,0 +1,32 @@ +prepareRandflakeContext(); + + $this->uuid = UUID::v7(); + $this->ulid = ULID::generate(); + $this->snowflake = Snowflake::generate(); + $this->sonyflake = Sonyflake::generate(); + $this->tbsl = TBSL::generate(); + $this->ksuid = KSUID::generate(); + $this->xid = XID::generate(); + $this->nanoid = NanoID::generate(); + $this->cuid2 = CUID2::generate(); + $this->randflake = Randflake::generate(42, $this->leaseStart, $this->leaseEnd, $this->randflakeSecret); + } + + #[Bench\Revs(1000)] + #[Bench\Iterations(5)] + #[Bench\ParamProviders('provideGenerationSubjects')] + public function benchGeneration(array $params): void + { + $this->runSubjectBench('generation', $params); + } + #[Bench\Revs(1000)] #[Bench\Iterations(5)] - public function benchSnowflakeParse(): void + #[Bench\ParamProviders('provideParseSubjects')] + public function benchParse(array $params): void { - $id = Snowflake::generate(); - Snowflake::parse($id); + $this->runSubjectBench('parse', $params); } #[Bench\Revs(1000)] @@ -30,16 +92,165 @@ public function benchUlidMonotonicBurstSameMs(): void #[Bench\Revs(1000)] #[Bench\Iterations(5)] - public function benchUuid7Generation(): void + #[Bench\ParamProviders('provideValidationSubjects')] + public function benchValidation(array $params): void { - UUID::v7(); + $this->runSubjectBench('validation', $params); } - #[Bench\Revs(1000)] - #[Bench\Iterations(5)] - public function benchUuidParse(): void + /** + * @return array + */ + public function provideGenerationSubjects(): array + { + return [ + 'cuid2' => ['subject' => 'cuid2'], + 'deterministic' => ['subject' => 'deterministic'], + 'ksuid' => ['subject' => 'ksuid'], + 'nanoid' => ['subject' => 'nanoid'], + 'opaque' => ['subject' => 'opaque'], + 'randflake' => ['subject' => 'randflake'], + 'snowflake' => ['subject' => 'snowflake'], + 'sonyflake' => ['subject' => 'sonyflake'], + 'tbsl' => ['subject' => 'tbsl'], + 'ulid' => ['subject' => 'ulid'], + 'uuid_v7' => ['subject' => 'uuid_v7'], + 'xid' => ['subject' => 'xid'], + ]; + } + + /** + * @return array + */ + public function provideParseSubjects(): array + { + return [ + 'ksuid' => ['subject' => 'ksuid'], + 'randflake_inspect' => ['subject' => 'randflake_inspect'], + 'randflake_parse' => ['subject' => 'randflake_parse'], + 'snowflake' => ['subject' => 'snowflake'], + 'sonyflake' => ['subject' => 'sonyflake'], + 'tbsl' => ['subject' => 'tbsl'], + 'ulid_get_time' => ['subject' => 'ulid_get_time'], + 'uuid' => ['subject' => 'uuid'], + 'xid' => ['subject' => 'xid'], + ]; + } + + /** + * @return array + */ + public function provideValidationSubjects(): array + { + return [ + 'cuid2' => ['subject' => 'cuid2'], + 'nanoid' => ['subject' => 'nanoid'], + 'randflake' => ['subject' => 'randflake'], + 'snowflake' => ['subject' => 'snowflake'], + 'sonyflake' => ['subject' => 'sonyflake'], + 'tbsl' => ['subject' => 'tbsl'], + ]; + } + + private function prepareRandflakeContext(): void + { + [$this->leaseStart, $this->leaseEnd, $this->randflakeSecret] = BenchBootstrap::randflakeContext(); + } + + /** + * @param array{subject?: mixed} $params + */ + private function runSubjectBench(string $operation, array $params): void + { + $subject = $this->subject($params); + $runner = match ($operation) { + 'generation' => [ + 'cuid2' => fn() => CUID2::generate(), + 'deterministic' => fn() => DeterministicId::fromPayload('payload', 24, 'bench'), + 'ksuid' => fn() => KSUID::generate(), + 'nanoid' => fn() => NanoID::generate(), + 'opaque' => fn() => OpaqueId::random(12), + 'randflake' => fn() => Randflake::generate(42, $this->leaseStart, $this->leaseEnd, $this->randflakeSecret), + 'snowflake' => fn() => Snowflake::generate(), + 'sonyflake' => fn() => Sonyflake::generate(), + 'tbsl' => fn() => TBSL::generate(), + 'ulid' => fn() => ULID::generate(), + 'uuid_v7' => fn() => UUID::v7(), + 'xid' => fn() => XID::generate(), + ], + 'parse' => [ + 'ksuid' => fn() => KSUID::parse($this->ksuid), + 'randflake_inspect' => fn() => Randflake::inspect($this->randflake, $this->randflakeSecret), + 'randflake_parse' => fn() => Randflake::parse($this->randflake, $this->randflakeSecret), + 'snowflake' => fn() => Snowflake::parse($this->snowflake), + 'sonyflake' => fn() => Sonyflake::parse($this->sonyflake), + 'tbsl' => fn() => TBSL::parse($this->tbsl), + 'ulid_get_time' => fn() => ULID::getTime($this->ulid), + 'uuid' => fn() => UUID::parse($this->uuid), + 'xid' => fn() => XID::parse($this->xid), + ], + 'validation' => $this->validationRunners(), + default => throw new InvalidArgumentException("Unknown benchmark operation: $operation"), + }; + $handler = $runner[$subject] ?? throw new InvalidArgumentException("Unknown {$operation} subject: $subject"); + $handler(); + } + + /** + * @param array{subject?: mixed} $params + */ + private function subject(array $params): string + { + $subject = $params['subject'] ?? null; + if (!is_string($subject) || $subject === '') { + throw new InvalidArgumentException('Benchmark subject is required.'); + } + + return $subject; + } + + private function validateCuid2(): void + { + CUID2::isValid($this->cuid2); + } + + private function validateNanoid(): void + { + NanoID::isValid($this->nanoid); + } + + private function validateRandflake(): void + { + Randflake::isValid($this->randflake); + } + + private function validateSnowflake(): void + { + Snowflake::isValid($this->snowflake); + } + + private function validateSonyflake(): void + { + Sonyflake::isValid($this->sonyflake); + } + + private function validateTbsl(): void + { + TBSL::isValid($this->tbsl); + } + + /** + * @return array + */ + private function validationRunners(): array { - $id = UUID::v7(); - UUID::parse($id); + return [ + 'cuid2' => fn() => $this->validateCuid2(), + 'nanoid' => fn() => $this->validateNanoid(), + 'randflake' => fn() => $this->validateRandflake(), + 'snowflake' => fn() => $this->validateSnowflake(), + 'sonyflake' => fn() => $this->validateSonyflake(), + 'tbsl' => fn() => $this->validateTbsl(), + ]; } } diff --git a/benchmarks/SequenceProviderBench.php b/benchmarks/SequenceProviderBench.php index 90f7f5c..88dec9a 100644 --- a/benchmarks/SequenceProviderBench.php +++ b/benchmarks/SequenceProviderBench.php @@ -4,29 +4,75 @@ namespace Infocyph\UID\Benchmarks; +use Infocyph\UID\Randflake; use Infocyph\UID\Snowflake; +use InvalidArgumentException; use PhpBench\Attributes as Bench; final class SequenceProviderBench { + private int $leaseEnd; + + private int $leaseStart; + + private string $secret; + public function __construct() { + require_once __DIR__ . '/BenchBootstrap.php'; + BenchBootstrap::load(); + [$this->leaseStart, $this->leaseEnd, $this->secret] = BenchBootstrap::randflakeContext(); + Snowflake::resetSequenceProvider(); + Randflake::resetSequenceProvider(); } #[Bench\Revs(500)] #[Bench\Iterations(5)] - public function benchFilesystemProvider(): void + #[Bench\ParamProviders('provideSequenceProviders')] + public function benchSequenceProvider(array $params): void { - Snowflake::useFilesystemSequenceProvider(); - Snowflake::generate(1, 1); + $subject = $params['subject'] ?? null; + if (!is_string($subject) || $subject === '') { + throw new InvalidArgumentException('Benchmark subject is required.'); + } + + switch ($subject) { + case 'snowflake_filesystem': + Snowflake::useFilesystemSequenceProvider(); + Snowflake::generate(1, 1); + + return; + case 'snowflake_in_memory': + Snowflake::useInMemorySequenceProvider(); + Snowflake::generate(1, 1); + + return; + case 'randflake_filesystem': + Randflake::useFilesystemSequenceProvider(); + Randflake::generate(1, $this->leaseStart, $this->leaseEnd, $this->secret); + + return; + case 'randflake_in_memory': + Randflake::useInMemorySequenceProvider(); + Randflake::generate(1, $this->leaseStart, $this->leaseEnd, $this->secret); + + return; + default: + throw new InvalidArgumentException("Unknown sequence provider subject: $subject"); + } } - #[Bench\Revs(500)] - #[Bench\Iterations(5)] - public function benchInMemoryProvider(): void + /** + * @return array + */ + public function provideSequenceProviders(): array { - Snowflake::useInMemorySequenceProvider(); - Snowflake::generate(1, 1); + return [ + 'snowflake_filesystem' => ['subject' => 'snowflake_filesystem'], + 'snowflake_in_memory' => ['subject' => 'snowflake_in_memory'], + 'randflake_filesystem' => ['subject' => 'randflake_filesystem'], + 'randflake_in_memory' => ['subject' => 'randflake_in_memory'], + ]; } } diff --git a/docs/compatibility.rst b/docs/compatibility.rst index dbd35ea..5978591 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -15,6 +15,7 @@ Non-UUID Families - ULID: Crockford Base32 ULID with monotonic and random modes. - Snowflake: 64-bit Twitter-style ID (41/5/5/12). - Sonyflake: 64-bit Sonyflake-style ID (39/16/8). +- Randflake: lease-bound encrypted 64-bit ID (30/17/17 before encryption). - TBSL: project-specific time-based sortable hex identifier. - NanoID and CUID2: URL-safe random IDs. - KSUID and XID: sortable short ID families. @@ -34,6 +35,7 @@ Binary and Alternate Encodings - UUID / ULID / TBSL: ``toBytes()`` / ``fromBytes()``. - UUID / ULID / Snowflake / Sonyflake / TBSL: ``toBase()`` / ``fromBase()``. +- Randflake: ``toBytes()`` / ``fromBytes()`` and ``toBase()`` / ``fromBase()``. - KSUID / XID: ``toBytes()`` / ``fromBytes()``. - Shared byte-level encoder: ``Infocyph\\UID\\Support\\BaseEncoder``. - Supported bases: ``16``, ``32``, ``36``, ``58``, ``62``. diff --git a/docs/helpers.rst b/docs/helpers.rst index 82dc413..7920020 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -51,6 +51,19 @@ Snowflake/Sonyflake/TBSL Helpers - ``tbsl_to_base(string $id, int $base)`` - ``tbsl_from_base(string $encoded, int $base)`` +Randflake Helpers +----------------- + +- ``randflake(int $nodeId, int $leaseStart, int $leaseEnd, string $secret)`` +- ``randflake_string(int $nodeId, int $leaseStart, int $leaseEnd, string $secret)`` +- ``randflake_is_valid(string $id)`` +- ``randflake_to_base(string $id, int $base)`` +- ``randflake_from_base(string $encoded, int $base)`` +- ``randflake_parse(string $id, string $secret)`` +- ``randflake_parse_string(string $id, string $secret)`` +- ``randflake_inspect(string $id, string $secret)`` +- ``randflake_inspect_string(string $id, string $secret)`` + Short/Random/Opaque Helpers --------------------------- diff --git a/docs/id-facade.rst b/docs/id-facade.rst index 9575fb8..e478b7f 100644 --- a/docs/id-facade.rst +++ b/docs/id-facade.rst @@ -16,6 +16,8 @@ Core Generation Methods - ``Id::snowflake()`` - ``Id::sonyflake()`` - ``Id::tbsl()`` +- ``Id::randflake(RandflakeConfig $config)`` +- ``Id::randflakeString(RandflakeConfig $config)`` - ``Id::nanoId()`` - ``Id::cuid2()`` - ``Id::ksuid()`` diff --git a/docs/index.rst b/docs/index.rst index 020a1b0..69ea35a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ It supports: - ULID - Snowflake - Sonyflake +- Randflake - TBSL - NanoID and CUID2 - KSUID and XID @@ -30,6 +31,7 @@ It supports: ulid snowflake sonyflake + randflake tbsl random-ids diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 79fa95c..6ab45a1 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -15,6 +15,7 @@ Using the ``Id`` Facade $snowflake = Id::snowflake(); $sonyflake = Id::sonyflake(); $tbsl = Id::tbsl(); + $randflake = \Infocyph\UID\Randflake::generate(42, time() - 5, time() + 300, 'super-secret-key'); $nano = Id::nanoId(21); $cuid2 = Id::cuid2(24); diff --git a/docs/randflake.rst b/docs/randflake.rst new file mode 100644 index 0000000..169a56e --- /dev/null +++ b/docs/randflake.rst @@ -0,0 +1,102 @@ +Randflake +========= + +Class: ``Infocyph\\UID\\Randflake`` + +Overview +-------- + +Randflake is a lease-bound 64-bit ID family with encrypted payload fields. + +Layout before encryption: + +- 30 bits timestamp (seconds from epoch offset ``1730000000``) +- 17 bits node ID +- 17 bits sequence + +Generation +---------- + +.. code-block:: php + + nodeId, + leaseStart: $config->leaseStart, + leaseEnd: $config->leaseEnd, + secret: $config->secret, + sequenceProvider: $config->sequenceProvider, + outputType: IdOutputType::STRING, + ), + ); + + return Randflake::encodeString((string) $id); + } + /** * @throws Exception */ diff --git a/src/Randflake.php b/src/Randflake.php new file mode 100644 index 0000000..81edc4b --- /dev/null +++ b/src/Randflake.php @@ -0,0 +1,436 @@ + + */ + private static array $lastTimestampByNode = []; + + /** + * @throws RandflakeException + */ + public static function decodeString(string $id): string + { + return NumericConversion::decimalFromBase( + $id, + 32, + 8, + static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException( + $message === '' ? 'randflake: invalid id' : 'randflake: invalid id', + 0, + $exception, + ), + ); + } + + /** + * @throws RandflakeException + */ + public static function encodeString(string $id): string + { + if (!self::isValid($id)) { + throw new RandflakeException('randflake: invalid id'); + } + + return BaseEncoder::encodeBytes(self::toBytes($id), 32); + } + + /** + * @throws RandflakeException + */ + public static function fromBase(string $encoded, int $base): string + { + return NumericConversion::decimalFromBase( + $encoded, + $base, + 8, + static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException($message, 0, $exception), + ); + } + + /** + * @throws RandflakeException + */ + public static function fromBytes(string $bytes): string + { + return NumericConversion::decimalFromBytes( + $bytes, + 8, + 'randflake: invalid id', + static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException($message, 0, $exception), + ); + } + + /** + * @throws RandflakeException|FileLockException + */ + public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string + { + return (string) self::generateInternal( + $nodeId, + $leaseStart, + $leaseEnd, + $secret, + IdOutputType::STRING, + null, + ); + } + + /** + * @throws RandflakeException|FileLockException + */ + public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string + { + return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); + } + + /** + * @throws RandflakeException|FileLockException + */ + public static function generateWithConfig(RandflakeConfig $config): int|string + { + return self::generateInternal( + $config->nodeId, + $config->leaseStart, + $config->leaseEnd, + $config->secret, + $config->outputType, + $config->sequenceProvider, + ); + } + + /** + * @return array{timestamp: int, node_id: int, sequence: int} + * @throws RandflakeException + */ + public static function inspect(string $id, string $secret): array + { + if (!self::isValid($id)) { + throw new RandflakeException('randflake: invalid id'); + } + + [$timestamp, $nodeId, $sequence] = self::inspectBytes( + self::toBytes($id), + self::validateSecret($secret), + ); + + return [ + 'timestamp' => $timestamp, + 'node_id' => $nodeId, + 'sequence' => $sequence, + ]; + } + + /** + * @return array{timestamp: int, node_id: int, sequence: int} + * @throws RandflakeException + */ + public static function inspectString(string $id, string $secret): array + { + return self::inspect(self::decodeString($id), $secret); + } + + public static function isValid(string $id): bool + { + return $id !== '' && ctype_digit($id); + } + + /** + * @return array{time: DateTimeImmutable, node_id: int, sequence: int} + * @throws Exception + */ + public static function parse(string $id, string $secret): array + { + if (!self::isValid($id)) { + throw new RandflakeException('randflake: invalid id'); + } + + [$timestamp, $nodeId, $sequence] = self::inspectBytes( + self::toBytes($id), + self::validateSecret($secret), + ); + + return [ + 'time' => new DateTimeImmutable('@' . $timestamp), + 'node_id' => $nodeId, + 'sequence' => $sequence, + ]; + } + + /** + * @return array{time: DateTimeImmutable, node_id: int, sequence: int} + * @throws Exception + */ + public static function parseString(string $id, string $secret): array + { + return self::parse(self::decodeString($id), $secret); + } + + /** + * @throws RandflakeException + */ + public static function toBase(string $id, int $base): string + { + return BaseEncoder::encodeBytes(self::toBytes($id), $base); + } + + /** + * @throws RandflakeException + */ + public static function toBytes(string $id): string + { + return NumericConversion::bytesFromDecimal( + $id, + 8, + self::isValid(...), + 'randflake: invalid id', + 'randflake: invalid id', + static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException($message, 0, $exception), + ); + } + + /** + * @throws RandflakeException|FileLockException + */ + private static function generateInternal( + int $nodeId, + int $leaseStart, + int $leaseEnd, + string $secret, + IdOutputType $outputType, + ?SequenceProviderInterface $sequenceProvider, + ): int|string { + self::validateNode($nodeId); + self::validateLeaseWindow($leaseStart, $leaseEnd); + $secret = self::validateSecret($secret); + + $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); + $stateKey = spl_object_id($resolvedSequenceProvider) . ':' . $nodeId; + $now = time(); + if ($now < $leaseStart || $now > $leaseEnd) { + throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); + } + + if ($now > self::MAX_TIMESTAMP) { + throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); + } + + $lastTimestamp = self::$lastTimestampByNode[$stateKey] ?? null; + if ($lastTimestamp !== null && $now < $lastTimestamp) { + throw new RandflakeException('randflake: timestamp consistency violation, the current time is less than the last time'); + } + + $sequence = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider) - 1; + if ($sequence > self::MAX_SEQUENCE) { + throw new RandflakeException( + "randflake: resource exhausted (generator can't handle current throughput, try using multiple randflake instances)", + ); + } + + self::$lastTimestampByNode[$stateKey] = $now; + + $plain = self::packPayload($now, $nodeId, $sequence); + $cipher = self::permute($plain, $secret, false); + $decimalId = DecimalBytes::fromBytes($cipher); + + return OutputFormatter::formatNumeric($decimalId, $outputType); + } + + /** + * @return array{0:int,1:int,2:int} + * @throws RandflakeException + */ + private static function inspectBytes(string $cipherBytes, string $secret): array + { + $plain = self::permute($cipherBytes, $secret, true); + [$timestamp, $nodeId, $sequence] = self::unpackPayload($plain); + + if ( + $timestamp < self::EPOCH_OFFSET + || $timestamp > self::MAX_TIMESTAMP + || $nodeId < 0 + || $nodeId > self::MAX_NODE + || $sequence < 0 + || $sequence > self::MAX_SEQUENCE + ) { + throw new RandflakeException('randflake: invalid id'); + } + + return [$timestamp, $nodeId, $sequence]; + } + + private static function packPayload(int $timestamp, int $nodeId, int $sequence): string + { + $timestampPart = $timestamp - self::EPOCH_OFFSET; + $high = (($timestampPart & self::MAX_TIMESTAMP_PART) << 2) | (($nodeId >> 15) & 0x03); + $low = (($nodeId & 0x7fff) << 17) | ($sequence & self::MAX_SEQUENCE); + + return pack('N2', $high, $low); + } + + /** + * Small secret-key permutation over 64-bit blocks to protect payload fields. + */ + private static function permute(string $block, string $secret, bool $decrypt): string + { + $parts = unpack('Nleft/Nright', $block); + $left = self::unpackedInt($parts, 'left'); + $right = self::unpackedInt($parts, 'right'); + $roundKeys = self::roundKeys($secret); + $mask = 0xffffffff; + + if ($decrypt) { + for ($round = count($roundKeys) - 1; $round >= 0; --$round) { + $nextRight = $left; + $nextLeft = ($right ^ self::roundFunction($left, $roundKeys[$round])) & $mask; + $left = $nextLeft; + $right = $nextRight; + } + } else { + foreach ($roundKeys as $roundKey) { + $nextLeft = $right; + $nextRight = ($left ^ self::roundFunction($right, $roundKey)) & $mask; + $left = $nextLeft; + $right = $nextRight; + } + } + + return pack('N2', $left, $right); + } + + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface + { + return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); + } + + private static function roundFunction(int $value, int $key): int + { + $mask = 0xffffffff; + $value &= $mask; + $leftRot = (($value << 5) | ($value >> 27)) & $mask; + $rightRot = (($value >> 3) | ($value << 29)) & $mask; + $mixed = ($leftRot + $key) & $mask; + $mixed ^= $rightRot; + $mixed ^= 0x9e3779b9; + + return $mixed & $mask; + } + + /** + * @return array + */ + private static function roundKeys(string $secret): array + { + $keys = []; + for ($round = 0; $round < 8; ++$round) { + $material = hash('sha256', $secret . ':' . $round, true); + $parts = unpack('Nkey', substr($material, 0, 4)); + $keys[] = self::unpackedInt($parts, 'key'); + } + + return $keys; + } + + /** + * @param array|false $parts + * @throws RandflakeException + */ + private static function unpackedInt(array|false $parts, string $key): int + { + if ($parts === false) { + throw new RandflakeException('randflake: invalid id'); + } + + $value = $parts[$key] ?? null; + if (!is_int($value)) { + throw new RandflakeException('randflake: invalid id'); + } + + return $value; + } + + /** + * @return array{0:int,1:int,2:int} + */ + private static function unpackPayload(string $payload): array + { + $parts = unpack('Nhigh/Nlow', $payload); + $high = self::unpackedInt($parts, 'high'); + $low = self::unpackedInt($parts, 'low'); + + $timestampPart = ($high >> 2) & self::MAX_TIMESTAMP_PART; + $nodeId = (($high & 0x03) << 15) | (($low >> 17) & 0x7fff); + $sequence = $low & self::MAX_SEQUENCE; + + return [$timestampPart + self::EPOCH_OFFSET, $nodeId, $sequence]; + } + + /** + * @throws RandflakeException + */ + private static function validateLeaseWindow(int $leaseStart, int $leaseEnd): void + { + if ($leaseStart > $leaseEnd || $leaseEnd > self::MAX_TIMESTAMP) { + throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); + } + } + + /** + * @throws RandflakeException + */ + private static function validateNode(int $nodeId): void + { + if ($nodeId < 0 || $nodeId > self::MAX_NODE) { + throw new RandflakeException('randflake: invalid node id, node id must be between 0 and 131071'); + } + } + + /** + * @throws RandflakeException + */ + private static function validateSecret(string $secret): string + { + if (strlen($secret) !== 16) { + throw new RandflakeException('randflake: invalid secret, secret must be 16 bytes long'); + } + + return $secret; + } +} diff --git a/src/Snowflake.php b/src/Snowflake.php index a76a238..6ed339b 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -16,7 +16,7 @@ use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\EpochGuard; use Infocyph\UID\Support\GetSequence; -use Infocyph\UID\Support\NumericIdCodec; +use Infocyph\UID\Support\NumericConversion; use Infocyph\UID\Support\OutputFormatter; final class Snowflake @@ -47,11 +47,7 @@ final class Snowflake */ public static function fromBase(string $encoded, int $base): string { - try { - return NumericIdCodec::decimalFromBase($encoded, $base, 8); - } catch (\InvalidArgumentException $exception) { - throw new SnowflakeException($exception->getMessage(), 0, $exception); - } + return self::decodeNumericBase($encoded, $base); } /** @@ -61,11 +57,7 @@ public static function fromBase(string $encoded, int $base): string */ public static function fromBytes(string $bytes): string { - try { - return NumericIdCodec::decimalFromBytes($bytes, 8); - } catch (\InvalidArgumentException $exception) { - throw new SnowflakeException('Snowflake binary data must be exactly 8 bytes', 0, $exception); - } + return self::decodeNumericBytes($bytes); } /** @@ -204,16 +196,7 @@ public static function toBase(string $id, int $base): string */ public static function toBytes(string $id): string { - try { - return NumericIdCodec::bytesFromDecimal( - $id, - 8, - self::isValid(...), - 'Invalid Snowflake ID string', - ); - } catch (\InvalidArgumentException $exception) { - throw new SnowflakeException('Unable to convert Snowflake ID to bytes', 0, $exception); - } + return self::encodeNumericBytes($id); } /** @@ -233,6 +216,38 @@ private static function assertNodeIds(int $datacenter, int $workerId): void } } + private static function decodeNumericBase(string $encoded, int $base): string + { + return NumericConversion::decimalFromBase( + $encoded, + $base, + 8, + static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception), + ); + } + + private static function decodeNumericBytes(string $bytes): string + { + return NumericConversion::decimalFromBytes( + $bytes, + 8, + 'Snowflake binary data must be exactly 8 bytes', + static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception), + ); + } + + private static function encodeNumericBytes(string $id): string + { + return NumericConversion::bytesFromDecimal( + $id, + 8, + self::isValid(...), + 'Invalid Snowflake ID string', + 'Unable to convert Snowflake ID to bytes', + static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception), + ); + } + /** * @throws SnowflakeException|FileLockException */ diff --git a/src/Support/NumericConversion.php b/src/Support/NumericConversion.php new file mode 100644 index 0000000..91ade22 --- /dev/null +++ b/src/Support/NumericConversion.php @@ -0,0 +1,67 @@ +getMessage(), $exception); + } + } + + /** + * @template T of \Throwable + * @param callable(string, \InvalidArgumentException):T $exceptionFactory + */ + public static function decimalFromBytes( + string $bytes, + int $length, + string $errorMessage, + callable $exceptionFactory, + ): string { + try { + return NumericIdCodec::decimalFromBytes($bytes, $length); + } catch (\InvalidArgumentException $exception) { + throw $exceptionFactory($errorMessage, $exception); + } + } +} diff --git a/src/functions.php b/src/functions.php index 9806a73..0c1b63d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -11,6 +11,7 @@ use Infocyph\UID\KSUID; use Infocyph\UID\NanoID; use Infocyph\UID\OpaqueId; +use Infocyph\UID\Randflake; use Infocyph\UID\Snowflake; use Infocyph\UID\Sonyflake; use Infocyph\UID\TBSL; @@ -26,6 +27,7 @@ function __uid_base_call(string $family, string $method, string $value, int $bas 'toBase' => [ 'uuid' => UUID::toBase(...), 'ulid' => ULID::toBase(...), + 'randflake' => Randflake::toBase(...), 'snowflake' => Snowflake::toBase(...), 'sonyflake' => Sonyflake::toBase(...), 'tbsl' => TBSL::toBase(...), @@ -33,6 +35,7 @@ function __uid_base_call(string $family, string $method, string $value, int $bas 'fromBase' => [ 'uuid' => UUID::fromBase(...), 'ulid' => ULID::fromBase(...), + 'randflake' => Randflake::fromBase(...), 'snowflake' => Snowflake::fromBase(...), 'sonyflake' => Sonyflake::fromBase(...), 'tbsl' => TBSL::fromBase(...), @@ -51,6 +54,7 @@ function __uid_is_valid(string $family, string $id): bool { return match ($family) { 'snowflake' => Snowflake::isValid($id), + 'randflake' => Randflake::isValid($id), 'sonyflake' => Sonyflake::isValid($id), 'tbsl' => TBSL::isValid($id), default => throw new InvalidArgumentException('Unsupported ID family for validation'), @@ -312,6 +316,71 @@ function snowflake(int $datacenter = 0, int $workerId = 0): string return Snowflake::generate($datacenter, $workerId); } } + +if (!function_exists('randflake')) { + /** + * @throws \Infocyph\UID\Exceptions\RandflakeException|\Infocyph\UID\Exceptions\FileLockException + */ + function randflake(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string + { + return Randflake::generate($nodeId, $leaseStart, $leaseEnd, $secret); + } +} + +if (!function_exists('randflake_string')) { + /** + * @throws \Infocyph\UID\Exceptions\RandflakeException|\Infocyph\UID\Exceptions\FileLockException + */ + function randflake_string(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string + { + return Randflake::generateString($nodeId, $leaseStart, $leaseEnd, $secret); + } +} + +if (!function_exists('randflake_parse')) { + /** + * @return array{time: DateTimeImmutable, node_id: int, sequence: int} + * @throws Exception + */ + function randflake_parse(string $id, string $secret): array + { + return Randflake::parse($id, $secret); + } +} + +if (!function_exists('randflake_parse_string')) { + /** + * @return array{time: DateTimeImmutable, node_id: int, sequence: int} + * @throws Exception + */ + function randflake_parse_string(string $id, string $secret): array + { + return Randflake::parseString($id, $secret); + } +} + +if (!function_exists('randflake_inspect')) { + /** + * @return array{timestamp: int, node_id: int, sequence: int} + * @throws \Infocyph\UID\Exceptions\RandflakeException + */ + function randflake_inspect(string $id, string $secret): array + { + return Randflake::inspect($id, $secret); + } +} + +if (!function_exists('randflake_inspect_string')) { + /** + * @return array{timestamp: int, node_id: int, sequence: int} + * @throws \Infocyph\UID\Exceptions\RandflakeException + */ + function randflake_inspect_string(string $id, string $secret): array + { + return Randflake::inspectString($id, $secret); + } +} + if (!function_exists('sonyflake')) { /** @throws SonyflakeException|FileLockException */ function sonyflake(int $machineId = 0): string @@ -326,12 +395,35 @@ function tbsl(int $machineId = 0, bool $sequenced = false): string } } +if (!function_exists('tbsl_to_base')) { + function tbsl_to_base(string $id, int $base): string + { + $family = 'tbsl'; + + return __uid_base_call($family, 'toBase', $id, $base); + } +} +if (!function_exists('tbsl_from_base')) { + function tbsl_from_base(string $encoded, int $base): string + { + $method = 'fromBase'; + + return __uid_base_call('tbsl', $method, $encoded, $base); + } +} + if (!function_exists('snowflake_is_valid')) { function snowflake_is_valid(string $id): bool { return Snowflake::isValid($id); } } +if (!function_exists('randflake_is_valid')) { + function randflake_is_valid(string $id): bool + { + return __uid_is_valid('randflake', $id); + } +} if (!function_exists('sonyflake_is_valid')) { function sonyflake_is_valid(string $id): bool { @@ -352,43 +444,43 @@ function tbsl_is_valid(string $id): bool if (!function_exists('snowflake_to_base')) { function snowflake_to_base(string $id, int $base): string { - return Snowflake::toBase($id, $base); + $family = 'snowflake'; + + return __uid_base_call($family, 'toBase', $id, $base); } } if (!function_exists('snowflake_from_base')) { function snowflake_from_base(string $encoded, int $base): string { - return Snowflake::fromBase($encoded, $base); + $method = 'fromBase'; + + return __uid_base_call('snowflake', $method, $encoded, $base); } } -if (!function_exists('sonyflake_to_base')) { - function sonyflake_to_base(string $id, int $base): string +if (!function_exists('randflake_to_base')) { + function randflake_to_base(string $id, int $base): string { - return __uid_base_call('sonyflake', 'toBase', $id, $base); + return Randflake::toBase($id, $base); } } -if (!function_exists('sonyflake_from_base')) { - function sonyflake_from_base(string $encoded, int $base): string +if (!function_exists('randflake_from_base')) { + function randflake_from_base(string $encoded, int $base): string { - return __uid_base_call('sonyflake', 'fromBase', $encoded, $base); + return Randflake::fromBase($encoded, $base); } } -if (!function_exists('tbsl_to_base')) { - function tbsl_to_base(string $id, int $base): string +if (!function_exists('sonyflake_to_base')) { + function sonyflake_to_base(string $id, int $base): string { - $family = 'tbsl'; - - return __uid_base_call($family, 'toBase', $id, $base); + return Sonyflake::toBase($id, $base); } } -if (!function_exists('tbsl_from_base')) { - function tbsl_from_base(string $encoded, int $base): string +if (!function_exists('sonyflake_from_base')) { + function sonyflake_from_base(string $encoded, int $base): string { - $method = 'fromBase'; - - return __uid_base_call('tbsl', $method, $encoded, $base); + return Sonyflake::fromBase($encoded, $base); } } diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php index 9931f33..93f1b93 100644 --- a/tests/IdFactoryTest.php +++ b/tests/IdFactoryTest.php @@ -3,6 +3,7 @@ use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Configuration\TBSLConfig; +use Infocyph\UID\Configuration\RandflakeConfig; use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Id; @@ -23,6 +24,13 @@ $snowflake = Id::snowflake(); $sonyflake = Id::sonyflake(); $tbsl = Id::tbsl(); + $now = time(); + $randflake = Id::randflake(new RandflakeConfig( + nodeId: 1, + leaseStart: $now - 5, + leaseEnd: $now + 300, + secret: 'super-secret-key', + )); expect($ksuid)->toBeString()->toHaveLength(27) ->and($xid)->toBeString()->toHaveLength(20) @@ -36,7 +44,8 @@ ->and($ulid)->toBeString()->toHaveLength(26) ->and((string)$snowflake)->toBeString()->not()->toBeEmpty() ->and((string)$sonyflake)->toBeString()->not()->toBeEmpty() - ->and((string)$tbsl)->toBeString()->toHaveLength(20); + ->and((string)$tbsl)->toBeString()->toHaveLength(20) + ->and((string)$randflake)->toBeString()->not()->toBeEmpty(); }); test('Id factory value objects', function () { @@ -64,9 +73,19 @@ $snowflake = Id::snowflake(new SnowflakeConfig(outputType: IdOutputType::INT)); $sonyflake = Id::sonyflake(new SonyflakeConfig(outputType: IdOutputType::INT)); $tbsl = Id::tbsl(new TBSLConfig(outputType: IdOutputType::BINARY)); + $now = time(); + $randflake = Id::randflake(new RandflakeConfig( + nodeId: 1, + leaseStart: $now - 5, + leaseEnd: $now + 300, + secret: 'super-secret-key', + outputType: IdOutputType::BINARY, + )); expect($snowflake)->toBeInt() ->and($sonyflake)->toBeInt() ->and($tbsl)->toBeString() - ->and(strlen($tbsl))->toBe(10); + ->and(strlen($tbsl))->toBe(10) + ->and($randflake)->toBeString() + ->and(strlen($randflake))->toBe(8); }); diff --git a/tests/RandflakeTest.php b/tests/RandflakeTest.php new file mode 100644 index 0000000..ccd3b9c --- /dev/null +++ b/tests/RandflakeTest.php @@ -0,0 +1,132 @@ +toBeTrue() + ->and($inspect['node_id'])->toBe(42) + ->and($parsed['node_id'])->toBe(42) + ->and($parsed['sequence'])->toBeGreaterThanOrEqual(0) + ->and($parsed['sequence'])->toBeLessThanOrEqual(Randflake::MAX_SEQUENCE) + ->and($parsed['time']->getTimestamp())->toBeBetween($leaseStart, $leaseEnd); +}); + +test('Randflake uniqueness on repeated generation', function () { + $now = time(); + $leaseStart = $now - 5; + $leaseEnd = $now + 300; + $secret = 'super-secret-key'; + + $ids = []; + for ($index = 0; $index < 200; $index++) { + $ids[] = Randflake::generate(7, $leaseStart, $leaseEnd, $secret); + } + + expect(count(array_unique($ids)))->toBe(count($ids)); +}); + +test('Randflake string encode and parse roundtrip', function () { + $now = time(); + $leaseStart = $now - 5; + $leaseEnd = $now + 300; + $secret = 'super-secret-key'; + + $stringId = Randflake::generateString(9, $leaseStart, $leaseEnd, $secret); + $decoded = Randflake::decodeString($stringId); + $parsed = Randflake::parseString($stringId, $secret); + + expect($stringId)->toMatch('/^[0-9a-v]+$/') + ->and($decoded)->toMatch('/^\d+$/') + ->and($parsed['node_id'])->toBe(9) + ->and($parsed['time']->getTimestamp())->toBeBetween($leaseStart, $leaseEnd); +}); + +test('Randflake bytes and base conversion roundtrip', function () { + $now = time(); + $leaseStart = $now - 5; + $leaseEnd = $now + 300; + $secret = 'super-secret-key'; + + $id = Randflake::generate(3, $leaseStart, $leaseEnd, $secret); + $bytes = Randflake::toBytes($id); + $encoded = Randflake::toBase($id, 58); + + expect(strlen($bytes))->toBe(8) + ->and(Randflake::fromBytes($bytes))->toBe($id) + ->and(Randflake::fromBase($encoded, 58))->toBe($id); +}); + +test('Randflake validates secret node and lease', function () { + $now = time(); + $validSecret = 'super-secret-key'; + + expect(fn () => Randflake::generate(-1, $now - 1, $now + 10, $validSecret)) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class) + ->and(fn () => Randflake::generate(1, $now - 1, $now + 10, 'too-short')) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class) + ->and(fn () => Randflake::generate(1, $now + 10, $now + 20, $validSecret)) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class); +}); + +test('Randflake config supports output modes', function () { + $now = time(); + $leaseStart = $now - 5; + $leaseEnd = $now + 300; + $secret = 'super-secret-key'; + + $stringId = Randflake::generateWithConfig( + new RandflakeConfig( + nodeId: 2, + leaseStart: $leaseStart, + leaseEnd: $leaseEnd, + secret: $secret, + outputType: IdOutputType::STRING, + ), + ); + + $binaryId = Randflake::generateWithConfig( + new RandflakeConfig( + nodeId: 2, + leaseStart: $leaseStart, + leaseEnd: $leaseEnd, + secret: $secret, + outputType: IdOutputType::BINARY, + ), + ); + + expect($stringId)->toBeString() + ->and($binaryId)->toBeString() + ->and(strlen($binaryId))->toBe(8); +}); + +test('Randflake global helper functions', function () { + $now = time(); + $leaseStart = $now - 5; + $leaseEnd = $now + 300; + $secret = 'super-secret-key'; + + $id = randflake(4, $leaseStart, $leaseEnd, $secret); + $stringId = randflake_string(4, $leaseStart, $leaseEnd, $secret); + $parsed = randflake_parse($id, $secret); + $parsedString = randflake_parse_string($stringId, $secret); + $inspected = randflake_inspect($id, $secret); + $inspectedString = randflake_inspect_string($stringId, $secret); + + expect(randflake_is_valid($id))->toBeTrue() + ->and(randflake_from_base(randflake_to_base($id, 36), 36))->toBe($id) + ->and($parsed['node_id'])->toBe(4) + ->and($parsedString['node_id'])->toBe(4) + ->and($inspected['node_id'])->toBe(4) + ->and($inspectedString['node_id'])->toBe(4); +});