diff --git a/README.md b/README.md index 3d34e51..544ca7b 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,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) +- Snowflake, Sonyflake, Randflake, TBSL - NanoID, CUID2, KSUID, XID - Opaque and deterministic IDs - Value objects and comparator utilities diff --git a/composer.json b/composer.json index 5ea7c4c..0eca7db 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "infocyph/uid", - "description": "UUID (RFC 9562), ULID, Snowflake ID, Sonyflake ID, and TBSL generator for PHP.", + "description": "UUID (RFC 9562), ULID, Snowflake, Sonyflake, Randflake, and TBSL generator for PHP.", "license": "MIT", "type": "library", "keywords": [ diff --git a/docs/randflake.rst b/docs/randflake.rst index 169a56e..82d8ba4 100644 --- a/docs/randflake.rst +++ b/docs/randflake.rst @@ -6,14 +6,19 @@ Class: ``Infocyph\\UID\\Randflake`` Overview -------- -Randflake is a lease-bound 64-bit ID family with encrypted payload fields. +Randflake is a lease-bound 64-bit ID family whose payload fields are obscured by +a reversible keyed permutation. -Layout before encryption: +Layout before permutation: - 30 bits timestamp (seconds from epoch offset ``1730000000``) - 17 bits node ID - 17 bits sequence +The permutation is not authenticated encryption: it does not prove integrity, +authorize access, or replace a standard encryption protocol. Treat Randflake +values as identifiers and enforce authorization independently. + Generation ---------- diff --git a/docs/random-ids.rst b/docs/random-ids.rst index 5bd1a83..d0300d0 100644 --- a/docs/random-ids.rst +++ b/docs/random-ids.rst @@ -24,6 +24,9 @@ Classes: $isCuid2 = CUID2::isValid($cuid2); $cuid2Info = CUID2::parse($cuid2); +CUID2 lengths are ``2..32`` and canonical values always begin with a lowercase +letter. + Validation/parsing outputs: - ``NanoID::parse()``: ``['isValid', 'length', 'alphabet']`` @@ -82,6 +85,9 @@ Class: ``Infocyph\\UID\\OpaqueId`` - ``OpaqueId::random($length)`` for short public IDs - ``OpaqueId::fromInt($value, $salt)`` and ``OpaqueId::toInt($token, $salt)`` for reversible obfuscation +The reversible integer form is obfuscation only. It is not encryption, +authentication, or authorization; always enforce access control independently. + .. code-block:: php 32) && throw new InvalidArgumentException( - 'length must be between 4 and 32', + ($length < 2 || $length > 32) && throw new InvalidArgumentException( + 'length must be between 2 and 32', ); - self::$counter ??= random_int(0, PHP_INT_MAX); - $hash = hash_init('sha3-512'); - hash_update($hash, (new DateTimeImmutable('now'))->format('Uv')); - hash_update($hash, (string) self::$counter++); - hash_update($hash, bin2hex(random_bytes($length))); - hash_update($hash, self::fingerprint()); - $encoded = self::hexToBase36(hash_final($hash)); - - return substr(str_pad($encoded, $length, '0'), 0, $length); + self::$counter ??= random_int(0, self::INITIAL_COUNTER_MAX); + $time = (string) (int) floor(microtime(true) * 1000); + $counter = (string) self::$counter++; + $salt = random_bytes($length); + $fingerprint = self::fingerprint(); + $hashInput = pack('N', strlen($time)) . $time + . pack('N', strlen($counter)) . $counter + . pack('N', strlen($salt)) . $salt + . pack('N', strlen($fingerprint)) . $fingerprint; + $encoded = BaseEncoder::encodeBytes(hash('sha3-512', $hashInput, true), 36); + $firstLetter = self::LETTERS[random_int(0, 25)]; + + return $firstLetter . substr($encoded, 1, $length - 1); } /** @@ -46,7 +54,7 @@ public static function isValid(string $id, ?int $length = null): bool return false; } - return preg_match('/^[0-9a-z]{4,32}$/', $id) === 1; + return preg_match('/^[a-z][0-9a-z]{1,31}$/D', $id) === 1; } /** @@ -69,40 +77,20 @@ public static function parse(string $id, ?int $length = null): array */ private static function fingerprint(): string { - $hash = hash_init('sha3-512'); - hash_update($hash, gethostname() ?: substr(str_shuffle('abcdefghjkmnpqrstvwxyz0123456789'), 0, 32)); - hash_update($hash, (string) random_int(1, 32768)); - hash_update($hash, bin2hex(random_bytes(32))); - - return hash_final($hash); - } - - /** - * Converts a base16 string to base36 using BCMath for large integer safety. - */ - private static function hexToBase36(string $hex): string - { - $hex = strtolower(ltrim($hex, '0')); - if ($hex === '') { - return '0'; - } - - $decimal = '0'; - $hexChars = '0123456789abcdef'; - foreach (str_split($hex) as $char) { - ($value = strpos($hexChars, $char)) !== false || throw new InvalidArgumentException( - 'Invalid hexadecimal string provided', - ); - $decimal = bcadd(bcmul($decimal, '16'), (string) $value); + if (self::$fingerprint !== null) { + return self::$fingerprint; } - $encoded = ''; - while (bccomp($decimal, '0') === 1) { - $remainder = (int) bcmod($decimal, '36'); - $encoded = self::$alphabet[$remainder] . $encoded; - $decimal = bcdiv($decimal, '36', 0); - } + $host = gethostname(); + $source = ($host === false ? '' : $host) + . "\0" + . getmypid() + . random_bytes(32); - return $encoded; + return self::$fingerprint = substr( + BaseEncoder::encodeBytes(hash('sha3-512', $source, true), 36), + 0, + 32, + ); } } diff --git a/src/Configuration/ResolvesCustomEpoch.php b/src/Configuration/ResolvesCustomEpoch.php index b4b7b75..e3b2184 100644 --- a/src/Configuration/ResolvesCustomEpoch.php +++ b/src/Configuration/ResolvesCustomEpoch.php @@ -28,7 +28,10 @@ private static function resolveEpochValue(DateTimeInterface|int|string|null $cus } $epoch = strtotime($customEpoch); + if ($epoch === false) { + throw new \InvalidArgumentException('Custom epoch must be a valid date string'); + } - return $epoch === false ? null : $epoch * 1000; + return $epoch * 1000; } } diff --git a/src/Configuration/ResolvesMachineId.php b/src/Configuration/ResolvesMachineId.php new file mode 100644 index 0000000..94badf0 --- /dev/null +++ b/src/Configuration/ResolvesMachineId.php @@ -0,0 +1,26 @@ +machineIdResolver === null) { + return $this->machineId; + } + + $machineId = ($this->machineIdResolver)(); + if (!is_int($machineId)) { + throw new \UnexpectedValueException('Machine ID resolver must return an integer'); + } + + return $machineId; + } +} diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index 3812699..92bbd35 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -17,7 +17,7 @@ private ?Closure $nodeResolver; /** - * @param callable():array{0:int,1:int}|null $nodeResolver + * @param callable():mixed|null $nodeResolver * @param DateTimeInterface|int|string|null $customEpoch Epoch in ms (int), parseable date string, or DateTime. */ public function __construct( @@ -33,7 +33,7 @@ public function __construct( } /** - * @return array{0:int,1:int} + * @return array{0:int, 1:int} */ public function resolveNode(): array { @@ -41,9 +41,16 @@ public function resolveNode(): array return [$this->datacenterId, $this->workerId]; } - /** @var array{0:int,1:int} $resolved */ $resolved = ($this->nodeResolver)(); + if ( + !is_array($resolved) + || !isset($resolved[0], $resolved[1]) + || !is_int($resolved[0]) + || !is_int($resolved[1]) + ) { + throw new \UnexpectedValueException('Snowflake node resolver must return two integer IDs'); + } - return $resolved; + return [$resolved[0], $resolved[1]]; } } diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index 52dc8b3..e6935d5 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -4,7 +4,6 @@ namespace Infocyph\UID\Configuration; -use Closure; use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Enums\IdOutputType; @@ -13,11 +12,10 @@ final readonly class SonyflakeConfig { use ResolvesCustomEpoch; - - private ?Closure $machineIdResolver; + use ResolvesMachineId; /** - * @param callable():int|null $machineIdResolver + * @param callable():mixed|null $machineIdResolver */ public function __construct( public int $machineId = 0, @@ -29,13 +27,4 @@ public function __construct( ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } - - public function resolveMachineId(): int - { - if ($this->machineIdResolver === null) { - return $this->machineId; - } - - return (int) ($this->machineIdResolver)(); - } } diff --git a/src/Configuration/TBSLConfig.php b/src/Configuration/TBSLConfig.php index 7d2ced1..78b3d53 100644 --- a/src/Configuration/TBSLConfig.php +++ b/src/Configuration/TBSLConfig.php @@ -4,17 +4,16 @@ namespace Infocyph\UID\Configuration; -use Closure; use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class TBSLConfig { - private ?Closure $machineIdResolver; + use ResolvesMachineId; /** - * @param callable():int|null $machineIdResolver + * @param callable():mixed|null $machineIdResolver */ public function __construct( public int $machineId = 0, @@ -26,13 +25,4 @@ public function __construct( ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } - - public function resolveMachineId(): int - { - if ($this->machineIdResolver === null) { - return $this->machineId; - } - - return (int) ($this->machineIdResolver)(); - } } diff --git a/src/DeterministicId.php b/src/DeterministicId.php index b8f0bde..ccecb6a 100644 --- a/src/DeterministicId.php +++ b/src/DeterministicId.php @@ -5,17 +5,32 @@ namespace Infocyph\UID; use Infocyph\UID\Support\BaseEncoder; +use InvalidArgumentException; final class DeterministicId { + private const MAX_LENGTH = 43; + /** * Generates a deterministic opaque ID from payload. */ public static function fromPayload(string $payload, int $length = 24, string $namespace = 'default'): string { + if ($length < 1) { + throw new InvalidArgumentException('length must be greater than zero'); + } + + if (str_contains($namespace, '|')) { + throw new InvalidArgumentException('namespace must not contain the reserved delimiter'); + } + + if ($length > self::MAX_LENGTH) { + throw new InvalidArgumentException('length must not exceed 43 characters'); + } + $hash = hash('sha3-256', $namespace . '|' . $payload, true); - $encoded = BaseEncoder::encodeBytes($hash, 62); + $encoded = str_pad(BaseEncoder::encodeBytes($hash, 62), self::MAX_LENGTH, '0'); - return substr(str_pad($encoded, $length, '0'), 0, $length); + return substr($encoded, 0, $length); } } diff --git a/src/Exceptions/SequenceTimestampException.php b/src/Exceptions/SequenceTimestampException.php new file mode 100644 index 0000000..0f48324 --- /dev/null +++ b/src/Exceptions/SequenceTimestampException.php @@ -0,0 +1,16 @@ +toString() : $left; $rightString = $right instanceof IdValueInterface ? $right->toString() : $right; - if (preg_match('/^\d+$/', $leftString) && preg_match('/^\d+$/', $rightString)) { + if (ctype_digit($leftString) && ctype_digit($rightString)) { return UnsignedDecimal::compare($leftString, $rightString); } diff --git a/src/KSUID.php b/src/KSUID.php index 88bc86f..060e8a0 100644 --- a/src/KSUID.php +++ b/src/KSUID.php @@ -13,7 +13,11 @@ final class KSUID implements IdAlgorithmInterface { - private static int $epoch = 1_400_000_000; + private const EPOCH = 1_400_000_000; + + private const MAX_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V'; + + private const MAX_TIMESTAMP_OFFSET = 0xffffffff; /** * @throws Exception @@ -34,8 +38,10 @@ public static function fromBytes(string $bytes): string */ public static function generate(?DateTimeInterface $dateTime = null): string { - $timestamp = ($dateTime ?? new DateTimeImmutable('now'))->getTimestamp() - self::$epoch; - $timestamp = max(0, $timestamp); + $timestamp = ($dateTime?->getTimestamp() ?? time()) - self::EPOCH; + if ($timestamp < 0 || $timestamp > self::MAX_TIMESTAMP_OFFSET) { + throw new \InvalidArgumentException('KSUID timestamp is outside its unsigned 32-bit lifetime'); + } $timeBytes = pack('N', $timestamp); $payload = random_bytes(16); @@ -46,7 +52,8 @@ public static function generate(?DateTimeInterface $dateTime = null): string public static function isValid(string $ksuid): bool { - return preg_match('/^[0-9A-Za-z]{27}$/', $ksuid) === 1; + return preg_match('/^[0-9A-Za-z]{27}$/D', $ksuid) === 1 + && strcmp($ksuid, self::MAX_ENCODED) <= 0; } /** @@ -61,7 +68,7 @@ public static function parse(string $ksuid): array } $bytes = self::toBytes($ksuid); - $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse KSUID timestamp') + self::$epoch; + $timestamp = BinaryUnpack::u32(substr($bytes, 0, 4), 'Unable to parse KSUID timestamp') + self::EPOCH; $data['time'] = new DateTimeImmutable('@' . $timestamp); $data['payload'] = bin2hex(substr($bytes, 4)); diff --git a/src/NanoID.php b/src/NanoID.php index 415eca7..85c9ebb 100644 --- a/src/NanoID.php +++ b/src/NanoID.php @@ -10,6 +10,8 @@ final class NanoID implements IdAlgorithmInterface { + private const MAX_LENGTH = 1_048_576; + /** * Generates a NanoID string with the requested size. * @@ -17,10 +19,17 @@ final class NanoID implements IdAlgorithmInterface */ public static function generate(int $length = 21): string { - ($length < 1) && throw new InvalidArgumentException('length must be greater than 0'); + if ($length < 1 || $length > self::MAX_LENGTH) { + throw new InvalidArgumentException('length must be between 1 and 1048576'); + } + + $byteLength = intdiv(($length * 3) + 3, 4); + if ($byteLength < 1) { + throw new \LogicException('Unable to calculate NanoID entropy length'); + } return substr( - str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(random_bytes($length))), + rtrim(strtr(base64_encode(random_bytes($byteLength)), '+/', '-_'), '='), 0, $length, ); diff --git a/src/OpaqueId.php b/src/OpaqueId.php index ddce19b..89fc24f 100644 --- a/src/OpaqueId.php +++ b/src/OpaqueId.php @@ -10,6 +10,10 @@ final class OpaqueId { + private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + private const MAX_RANDOM_LENGTH = 1024; + /** * Encodes an integer in a hashid-style opaque token. */ @@ -29,13 +33,36 @@ public static function fromInt(int $value, string $salt = ''): string */ public static function random(int $length = 12): string { - if ($length < 1) { - throw new InvalidArgumentException('length must be greater than 0'); + if ($length < 1 || $length > self::MAX_RANDOM_LENGTH) { + throw new InvalidArgumentException('length must be between 1 and 1024'); } - $bytes = random_bytes(max(8, $length)); + $id = ''; + $idLength = 0; + while ($idLength < $length) { + $remaining = $length - $idLength; + $chunkLength = intdiv(($remaining * 256) + 247, 248); + if ($chunkLength < 1) { + throw new \LogicException('Unable to calculate opaque ID entropy length'); + } + + $bytes = random_bytes($chunkLength); + $byteLength = strlen($bytes); + for ($index = 0; $index < $byteLength; ++$index) { + $value = ord($bytes[$index]); + if ($value >= 248) { + continue; + } + + $id .= self::ALPHABET[$value % 62]; + ++$idLength; + if ($idLength === $length) { + break; + } + } + } - return substr(BaseEncoder::encodeBytes($bytes, 62), 0, $length); + return $id; } /** diff --git a/src/Randflake.php b/src/Randflake.php index 81edc4b..98bc701 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -10,6 +10,7 @@ use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\RandflakeException; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; @@ -17,6 +18,7 @@ use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; use Infocyph\UID\Support\OutputFormatter; +use Infocyph\UID\Support\UnsignedDecimal; final class Randflake { @@ -38,10 +40,8 @@ final class Randflake public const TIMESTAMP_BITS = 30; - /** - * @var array - */ - private static array $lastTimestampByNode = []; + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastTimestampByProvider = null; /** * @throws RandflakeException @@ -169,7 +169,9 @@ public static function inspectString(string $id, string $secret): array public static function isValid(string $id): bool { - return $id !== '' && ctype_digit($id); + return $id !== '' + && ctype_digit($id) + && UnsignedDecimal::compare($id, '18446744073709551615') <= 0; } /** @@ -242,7 +244,8 @@ private static function generateInternal( $secret = self::validateSecret($secret); $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); - $stateKey = spl_object_id($resolvedSequenceProvider) . ':' . $nodeId; + self::$lastTimestampByProvider ??= new \WeakMap(); + $providerState = self::$lastTimestampByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); $now = time(); if ($now < $leaseStart || $now > $leaseEnd) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); @@ -252,19 +255,37 @@ private static function generateInternal( throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); } - $lastTimestamp = self::$lastTimestampByNode[$stateKey] ?? null; + $lastTimestamp = $providerState[$nodeId] ?? 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; + try { + $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + } catch (SequenceTimestampException $exception) { + $now = time(); + if ($now < $exception->lastTimestamp) { + throw new RandflakeException( + 'randflake: timestamp consistency violation, the current time is less than the persisted time', + 0, + $exception, + ); + } + + $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + } + if ($sequenceValue < 1) { + throw new RandflakeException('randflake: sequence provider must return a positive integer'); + } + + $sequence = $sequenceValue - 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; + $providerState[$nodeId] = $now; $plain = self::packPayload($now, $nodeId, $sequence); $cipher = self::permute($plain, $secret, false); diff --git a/src/Sequence/CallbackSequenceProvider.php b/src/Sequence/CallbackSequenceProvider.php index 76f6c8d..e4ed13d 100644 --- a/src/Sequence/CallbackSequenceProvider.php +++ b/src/Sequence/CallbackSequenceProvider.php @@ -25,8 +25,8 @@ public function __construct(callable $callback) public function next(string $type, int $machineId, int $timestamp): int { $value = ($this->callback)($type, $machineId, $timestamp); - if ($value < 0) { - throw new FileLockException('Custom sequence callback must return a non-negative integer'); + if ($value < 1) { + throw new FileLockException('Custom sequence callback must return a positive integer'); } return $value; diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 8b7a5f0..0a0c353 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -5,7 +5,9 @@ namespace Infocyph\UID\Sequence; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Support\FileLock; +use InvalidArgumentException; final readonly class FilesystemSequenceProvider implements SequenceProviderInterface { @@ -52,6 +54,10 @@ private function acquireLock(string $fileLocation) private function sequenceFileLocation(string $type, int $machineId): string { + if (preg_match('/^[A-Za-z0-9_-]+$/D', $type) !== 1) { + throw new InvalidArgumentException('Sequence type may contain only letters, numbers, underscores, and hyphens'); + } + return $this->baseDirectory . DIRECTORY_SEPARATOR . "uid-$type-$machineId.seq"; } @@ -63,25 +69,45 @@ private function updateSequence($handle, int $timestamp): int { $sequence = 0; $line = stream_get_contents($handle); + if ($line === false) { + throw new FileLockException('Unable to read sequence state'); + } - if ($line !== false && trim($line) !== '') { - [$lastTimestamp, $lastSequence] = explode(',', trim($line)); - $lastTimestamp = (int) $lastTimestamp; + $line = trim($line); + if ($line !== '') { + if (preg_match('/^(0|[1-9]\d*),(0|[1-9]\d*)$/D', $line) !== 1) { + throw new FileLockException('Sequence state is malformed'); + } + + $parts = explode(',', $line, 2); + $lastTimestamp = filter_var($parts[0], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + $lastSequence = filter_var($parts[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if ($lastTimestamp === false || $lastSequence === false) { + throw new FileLockException('Sequence state is malformed'); + } - if ($lastTimestamp === $timestamp) { - $sequence = (int) $lastSequence; + if ($lastTimestamp > $timestamp) { + throw new SequenceTimestampException($lastTimestamp, $timestamp); } + + $sequence = $lastTimestamp === $timestamp ? $lastSequence : 0; } - $sequence++; + if ($sequence === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted'); + } - rewind($handle); - fwrite($handle, "$timestamp,$sequence"); - $position = ftell($handle); - ($position !== false && $position >= 0) || throw new FileLockException( - 'Unable to determine sequence file write position', - ); - ftruncate($handle, $position); + ++$sequence; + $state = "$timestamp,$sequence"; + + rewind($handle) || throw new FileLockException('Unable to rewind sequence file'); + $written = fwrite($handle, $state); + if ($written === false || $written !== strlen($state)) { + throw new FileLockException('Unable to write complete sequence state'); + } + + ftruncate($handle, $written) || throw new FileLockException('Unable to truncate sequence file'); + fflush($handle) || throw new FileLockException('Unable to flush sequence state'); return $sequence; } diff --git a/src/Sequence/InMemorySequenceProvider.php b/src/Sequence/InMemorySequenceProvider.php index 59b82a4..5f0265e 100644 --- a/src/Sequence/InMemorySequenceProvider.php +++ b/src/Sequence/InMemorySequenceProvider.php @@ -4,6 +4,9 @@ namespace Infocyph\UID\Sequence; +use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Exceptions\SequenceTimestampException; + final class InMemorySequenceProvider implements SequenceProviderInterface { /** @@ -17,8 +20,18 @@ public function next(string $type, int $machineId, int $timestamp): int $last = $this->state[$key] ?? null; $sequence = 1; - if ($last !== null && $last['timestamp'] === $timestamp) { - $sequence = $last['sequence'] + 1; + if ($last !== null) { + if ($last['timestamp'] > $timestamp) { + throw new SequenceTimestampException($last['timestamp'], $timestamp); + } + + if ($last['timestamp'] === $timestamp) { + if ($last['sequence'] === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted'); + } + + $sequence = $last['sequence'] + 1; + } } $this->state[$key] = [ diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index d6e0e4a..9d386bf 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -6,7 +6,9 @@ use Closure; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Support\FileLock; +use InvalidArgumentException; use Psr\SimpleCache\CacheInterface; use Throwable; @@ -19,11 +21,15 @@ */ public function __construct( private CacheInterface $cache, - private string $prefix = 'uid:seq:', + private string $prefix = 'uid.seq.', private int $waitTime = 1_000, private int $maxAttempts = 1_000, ?callable $synchronizer = null, ) { + if (preg_match('/^[A-Za-z0-9_.]*$/D', $this->prefix) !== 1) { + throw new InvalidArgumentException('Cache key prefix contains characters not guaranteed by PSR-16'); + } + $this->synchronizer = $synchronizer ? $synchronizer(...) : null; } @@ -61,6 +67,8 @@ public function next(string $type, int $machineId, int $timestamp): int try { return $this->nextFromCacheState($key, $timestamp); + } catch (FileLockException $exception) { + throw $exception; } catch (Throwable $exception) { throw new FileLockException( 'Failed to read/write sequence state from PSR cache for key: ' . $key, @@ -79,7 +87,7 @@ public function next(string $type, int $machineId, int $timestamp): int */ private function acquireLock(string $key) { - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-cache-lock-' . md5($key) . '.lck'; + $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-cache-lock-' . hash('sha256', $key) . '.lck'; return FileLock::acquire( $lockFile, @@ -92,23 +100,54 @@ private function acquireLock(string $key) private function key(string $type, int $machineId): string { - return $this->prefix . $type . ':' . $machineId; + if (preg_match('/^[A-Za-z0-9_.]+$/D', $type) !== 1) { + throw new InvalidArgumentException('Sequence type contains characters not guaranteed by PSR-16'); + } + + $key = $this->prefix . $type . '.' . $machineId; + if (strlen($key) > 64) { + throw new InvalidArgumentException('Sequence cache key must not exceed 64 characters'); + } + + return $key; } private function nextFromCacheState(string $key, int $timestamp): int { $state = $this->cache->get($key); $sequence = 1; - if ( - is_array($state) - && ($state['timestamp'] ?? null) === $timestamp - && isset($state['sequence']) - && is_int($state['sequence']) - ) { - $sequence = $state['sequence'] + 1; + if ($state !== null) { + if ( + !is_array($state) + || !isset($state['timestamp'], $state['sequence']) + || !is_int($state['timestamp']) + || !is_int($state['sequence']) + || $state['timestamp'] < 0 + || $state['sequence'] < 1 + ) { + throw new FileLockException('Cached sequence state is malformed for key: ' . $key); + } + + if ($state['timestamp'] > $timestamp) { + throw new SequenceTimestampException( + $state['timestamp'], + $timestamp, + 'Sequence timestamp moved backwards for key: ' . $key, + ); + } + + if ($state['timestamp'] === $timestamp) { + if ($state['sequence'] === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted for key: ' . $key); + } + + $sequence = $state['sequence'] + 1; + } } - $this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence]); + if (!$this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence])) { + throw new FileLockException('Failed to persist sequence state for key: ' . $key); + } return $sequence; } diff --git a/src/Sequence/SequenceProviderInterface.php b/src/Sequence/SequenceProviderInterface.php index b0bb119..1a966c3 100644 --- a/src/Sequence/SequenceProviderInterface.php +++ b/src/Sequence/SequenceProviderInterface.php @@ -7,7 +7,7 @@ interface SequenceProviderInterface { /** - * Returns the next sequence for a given type/machine/timestamp key. + * Returns the next positive sequence for a given type/machine/timestamp key. */ public function next(string $type, int $machineId, int $timestamp): int; } diff --git a/src/Snowflake.php b/src/Snowflake.php index 6ed339b..bde8127 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -10,6 +10,7 @@ use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SnowflakeException; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; @@ -18,15 +19,14 @@ use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; use Infocyph\UID\Support\OutputFormatter; +use Infocyph\UID\Support\UnsignedDecimal; final class Snowflake { use GetSequence; - /** - * @var array - */ - private static array $lastStateBySequenceKey = []; + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastStateByProvider = null; private static int $lastTimestamp = 0; @@ -104,7 +104,10 @@ public static function generateWithConfig(SnowflakeConfig $config): int|string */ public static function isValid(string $id): bool { - return $id !== '' && $id !== '0' && ctype_digit($id); + return $id !== '' + && $id !== '0' + && ctype_digit($id) + && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } /** @@ -130,6 +133,10 @@ public static function parse(string $id): array */ public static function parseWithEpoch(string $id, int $startTimestamp): array { + if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) { + throw new SnowflakeException('Invalid Snowflake ID string'); + } + $binaryId = decbin((int) $id); $timestamp = (int) bindec(substr($binaryId, 0, -22)) + $startTimestamp; [$seconds, $fraction] = self::timestampParts($timestamp); @@ -216,6 +223,22 @@ private static function assertNodeIds(int $datacenter, int $workerId): void } } + /** + * @throws SnowflakeException + */ + private static function assertTimestampRange(int $currentTime, int $startTimestamp): void + { + $elapsed = $currentTime - $startTimestamp; + $maxTimestamp = -1 ^ (-1 << self::$maxTimestampLength); + if ($elapsed < 0) { + throw new SnowflakeException('Snowflake epoch must not be in the future'); + } + + if ($elapsed > $maxTimestamp) { + throw new SnowflakeException('Exceeding the maximum life cycle of the Snowflake algorithm'); + } + } + private static function decodeNumericBase(string $encoded, int $base): string { return NumericConversion::decimalFromBase( @@ -261,31 +284,35 @@ private static function generateInternal( ): int|string { self::assertNodeIds($datacenter, $workerId); - $currentTime = (int) (new DateTimeImmutable('now'))->format('Uv'); + $currentTime = (int) floor(microtime(true) * 1000); + self::assertTimestampRange($currentTime, $startTimestamp); + if ($currentTime < self::$lastTimestamp) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SnowflakeException('Clock moved backwards while generating Snowflake ID'); } $currentTime = self::waitUntil(self::$lastTimestamp); + self::assertTimestampRange($currentTime, $startTimestamp); } $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); $sequenceKey = ($datacenter << self::$maxWorkIdLength) | $workerId; - $stateKey = spl_object_id($resolvedSequenceProvider) . ':' . $sequenceKey; + self::$lastStateByProvider ??= new \WeakMap(); + $providerState = self::$lastStateByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); $maxSequence = -1 ^ (-1 << self::$maxSequenceLength); while (true) { - while (($sequence = self::sequence( + [$currentTime, $sequence] = self::nextSequenceAtValidTimestamp( $currentTime, $sequenceKey, - 'snowflake', + $startTimestamp, + $maxSequence, + $clockBackwardPolicy, $resolvedSequenceProvider, - )) > $maxSequence) { - ++$currentTime; - } + ); - $lastState = self::$lastStateBySequenceKey[$stateKey] ?? null; + $lastState = $providerState[$sequenceKey] ?? null; if ($lastState === null) { break; @@ -297,7 +324,8 @@ private static function generateInternal( $currentTime < $lastState['timestamp'] || ($currentTime === $lastState['timestamp'] && $sequence <= $lastState['sequence']) ) { - $currentTime = $lastState['timestamp'] + 1; + $currentTime = self::waitUntil($lastState['timestamp'] + 1); + self::assertTimestampRange($currentTime, $startTimestamp); continue; } @@ -306,7 +334,7 @@ private static function generateInternal( } self::$lastTimestamp = $currentTime; - self::$lastStateBySequenceKey[$stateKey] = [ + $providerState[$sequenceKey] = [ 'timestamp' => $currentTime, 'sequence' => $sequence, ]; @@ -328,7 +356,46 @@ private static function generateInternal( */ private static function getStartTimeStamp(): int { - return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); + return self::$startTime ??= 1_577_836_800_000; + } + + /** + * @return array{0:int, 1:int} + * @throws FileLockException|SnowflakeException + */ + private static function nextSequenceAtValidTimestamp( + int $currentTime, + int $sequenceKey, + int $startTimestamp, + int $maxSequence, + ClockBackwardPolicy $clockBackwardPolicy, + SequenceProviderInterface $sequenceProvider, + ): array { + while (true) { + try { + $sequence = self::sequence($currentTime, $sequenceKey, 'snowflake', $sequenceProvider); + } catch (SequenceTimestampException $exception) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new SnowflakeException( + 'Clock moved backwards while generating Snowflake ID', + 0, + $exception, + ); + } + + $currentTime = self::waitUntil($exception->lastTimestamp); + self::assertTimestampRange($currentTime, $startTimestamp); + + continue; + } + + if ($sequence <= $maxSequence) { + return [$currentTime, $sequence]; + } + + $currentTime = self::waitUntil($currentTime + 1); + self::assertTimestampRange($currentTime, $startTimestamp); + } } private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface @@ -341,18 +408,16 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov */ private static function timestampParts(int $timestamp): array { - $time = str_split((string) $timestamp, 10); - $time[1] ??= '0'; - - return [$time[0], $time[1]]; + return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } private static function waitUntil(int $timestamp): int { - do { + $now = (int) floor(microtime(true) * 1000); + while ($now < $timestamp) { usleep(1000); - $now = (int) (new DateTimeImmutable('now'))->format('Uv'); - } while ($now < $timestamp); + $now = (int) floor(microtime(true) * 1000); + } return $now; } diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 7b1b1e0..7cfa5c5 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -10,6 +10,7 @@ use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Enums\IdOutputType; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SonyflakeException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; @@ -17,12 +18,13 @@ use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericIdCodec; use Infocyph\UID\Support\OutputFormatter; +use Infocyph\UID\Support\UnsignedDecimal; final class Sonyflake { use GetSequence; - private static int $lastElapsedTime = 0; + private static int $lastWallTime = 0; private static int $maxMachineIdLength = 16; @@ -96,7 +98,10 @@ public static function generateWithConfig(SonyflakeConfig $config): int|string */ public static function isValid(string $id): bool { - return preg_match('/^\d+$/', $id) === 1 && $id !== '0'; + return $id !== '' + && $id !== '0' + && ctype_digit($id) + && UnsignedDecimal::compare($id, (string) PHP_INT_MAX) <= 0; } /** @@ -119,6 +124,10 @@ public static function parse(string $id): array */ public static function parseWithEpoch(string $id, int $startTimestamp): array { + if (!self::isValid($id) || UnsignedDecimal::compare($id, (string) PHP_INT_MAX) === 1) { + throw new SonyflakeException('Invalid Sonyflake ID string'); + } + $parts = self::extractParts($id, $startTimestamp); return [ @@ -201,9 +210,9 @@ private static function decodeNumeric(callable $operation, ?string $customMessag /** * Calculates the elapsed time in 10ms units. */ - private static function elapsedTime(int $startTimestamp): int + private static function elapsedTime(int $currentTime, int $startTimestamp): int { - return floor(((new DateTimeImmutable('now'))->format('Uv') - $startTimestamp) / 10) | 0; + return intdiv($currentTime - $startTimestamp, 10); } /** @@ -214,6 +223,10 @@ private static function elapsedTime(int $startTimestamp): int */ private static function ensureEffectiveRuntime(int $elapsedTime): void { + if ($elapsedTime < 0) { + throw new SonyflakeException('Sonyflake epoch must not be in the future'); + } + if ($elapsedTime > (-1 ^ (-1 << self::$maxTimestampLength))) { throw new SonyflakeException('Exceeding the maximum life cycle of the algorithm'); } @@ -253,25 +266,48 @@ private static function generateInternal( throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID."); } - $elapsedTime = self::elapsedTime($startTimestamp); - - if ($elapsedTime < self::$lastElapsedTime) { + $currentTime = (int) floor(microtime(true) * 1000); + if ($currentTime < self::$lastWallTime) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); } - $elapsedTime = self::waitUntilElapsed(self::$lastElapsedTime, $startTimestamp); + $currentTime = self::waitUntilWallTime(self::$lastWallTime); } - while (($sequence = self::sequence( - $elapsedTime, - $machineId, - 'sonyflake', - $sequenceProvider, - )) > (-1 ^ (-1 << self::$maxSequenceLength))) { + $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); + self::ensureEffectiveRuntime($elapsedTime); + $sequenceType = 'sonyflake_' . $startTimestamp; + + while (true) { + try { + $sequence = self::sequence( + $elapsedTime, + $machineId, + $sequenceType, + $sequenceProvider, + ); + } catch (SequenceTimestampException $exception) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new SonyflakeException( + 'Clock moved backwards while generating Sonyflake ID', + 0, + $exception, + ); + } + + $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp); + + continue; + } + + if ($sequence <= (-1 ^ (-1 << self::$maxSequenceLength))) { + break; + } + $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); } - self::$lastElapsedTime = $elapsedTime; + self::$lastWallTime = max($currentTime, $startTimestamp + ($elapsedTime * 10)); self::ensureEffectiveRuntime($elapsedTime); @@ -287,17 +323,27 @@ private static function generateInternal( */ private static function getStartTimeStamp(): int { - return self::$startTime ??= (strtotime('2020-01-01 00:00:00') * 1000); + return self::$startTime ??= 1_577_836_800_000; } private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int { - $next = self::elapsedTime($startTimestamp); + $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); while ($next <= $elapsedTime) { usleep(1000); - $next = self::elapsedTime($startTimestamp); + $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); } return $next; } + + private static function waitUntilWallTime(int $lastTime): int + { + do { + usleep(1000); + $currentTime = (int) floor(microtime(true) * 1000); + } while ($currentTime < $lastTime); + + return $currentTime; + } } diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 47366d7..5dfece5 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -16,34 +16,38 @@ final class BaseEncoder 62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', ]; + private const MAX_BYTE_LENGTH = 1_048_576; + /** * Decodes one of supported bases (16/32/36/58/62) into bytes. */ public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string { - $alphabet = self::alphabet($base); - $decimal = '0'; + if ($encoded === '') { + throw new InvalidArgumentException('Encoded value must not be empty'); + } - foreach (str_split($encoded) as $char) { - $index = strpos($alphabet, $char); - $index !== false || throw new InvalidArgumentException('Invalid character for base ' . $base); - $decimal = bcadd(bcmul($decimal, (string) $base), (string) $index); + if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) { + throw new InvalidArgumentException('Byte length must be between 1 and 1048576'); } - $hex = ''; - while ($decimal !== '0') { - $remainder = (int) bcmod($decimal, '16'); - $hex = dechex($remainder) . $hex; - $decimal = bcdiv($decimal, '16', 0); + $alphabet = self::alphabet($base); + $maxEncodedLength = (int) ceil(($bytesLength * 8) / log($base, 2)); + if (strlen($encoded) > $maxEncodedLength) { + throw new InvalidArgumentException('Encoded value exceeds target byte length'); } - $hex = str_pad($hex, $bytesLength * 2, '0', STR_PAD_LEFT); - $bytes = hex2bin($hex); - if ($bytes === false || strlen($bytes) !== $bytesLength) { - throw new InvalidArgumentException('Invalid encoded value for target byte length'); + $decimal = '0'; + $encodedLength = strlen($encoded); + + for ($index = 0; $index < $encodedLength; ++$index) { + $char = $encoded[$index]; + $alphabetIndex = strpos($alphabet, $char); + $alphabetIndex !== false || throw new InvalidArgumentException('Invalid character for base ' . $base); + $decimal = bcadd(bcmul($decimal, (string) $base), (string) $alphabetIndex); } - return $bytes; + return DecimalBytes::toFixedBytes($decimal, $bytesLength); } /** @@ -51,6 +55,11 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt */ public static function encodeBytes(string $bytes, int $base): string { + $byteLength = strlen($bytes); + if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { + throw new InvalidArgumentException('Byte length must be between 1 and 1048576'); + } + $alphabet = self::alphabet($base); $decimal = self::bytesToDecimal($bytes); @@ -75,14 +84,6 @@ private static function alphabet(int $base): string private static function bytesToDecimal(string $bytes): string { - $decimal = '0'; - foreach (str_split(bin2hex($bytes)) as $char) { - $decimal = bcadd( - bcmul($decimal, '16'), - (string) hexdec($char), - ); - } - - return $decimal; + return DecimalBytes::fromBytes($bytes); } } diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php index bdaf535..6383d34 100644 --- a/src/Support/DecimalBytes.php +++ b/src/Support/DecimalBytes.php @@ -6,11 +6,14 @@ final class DecimalBytes { + private const MAX_BYTE_LENGTH = 1_048_576; + public static function fromBytes(string $bytes): string { $decimal = '0'; - foreach (str_split(bin2hex($bytes)) as $char) { - $decimal = bcadd(bcmul($decimal, '16'), (string) hexdec($char)); + $length = strlen($bytes); + for ($index = 0; $index < $length; ++$index) { + $decimal = bcadd(bcmul($decimal, '256'), (string) ord($bytes[$index])); } return $decimal; @@ -21,32 +24,36 @@ public static function fromBytes(string $bytes): string */ public static function toFixedBytes(string $decimal, int $byteLength): string { - if ($byteLength < 1) { - throw new \InvalidArgumentException('Byte length must be greater than zero'); + if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { + throw new \InvalidArgumentException('Byte length must be between 1 and 1048576'); } if (preg_match('/^\d+$/', $decimal) !== 1) { throw new \InvalidArgumentException('Decimal value must contain only digits'); } - $hex = ''; $value = UnsignedDecimal::normalize($decimal); - while ($value !== '0') { - $remainder = (int) bcmod($value, '16'); - $hex = dechex($remainder) . $hex; - $value = bcdiv($value, '16', 0); + $maxDecimalDigits = (int) ceil($byteLength * log10(256)); + if (strlen($value) > $maxDecimalDigits) { + throw new \InvalidArgumentException('Decimal value exceeds target byte length'); } - if (strlen($hex) > ($byteLength * 2)) { - throw new \InvalidArgumentException('Decimal value exceeds target byte length'); + $bytes = ''; + while ($value !== '0') { + $remainder = (int) bcmod($value, '256'); + if ($remainder < 0 || $remainder > 255) { + throw new \LogicException('Decimal byte remainder is outside the byte range'); + } + + $bytes = chr($remainder) . $bytes; + $value = bcdiv($value, '256', 0); } - $hex = str_pad($hex, $byteLength * 2, '0', STR_PAD_LEFT); - $bytes = hex2bin($hex); - if ($bytes === false) { - throw new \InvalidArgumentException('Unable to convert decimal value to bytes'); + $valueLength = strlen($bytes); + if ($valueLength > $byteLength) { + throw new \InvalidArgumentException('Decimal value exceeds target byte length'); } - return $bytes; + return str_repeat("\0", $byteLength - $valueLength) . $bytes; } } diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index d7da0c3..060b985 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -65,7 +65,7 @@ public static function useSequenceCallback(callable $callback): void */ public static function useSimpleCacheSequenceProvider( CacheInterface $cache, - string $prefix = 'uid:seq:', + string $prefix = 'uid.seq.', int $waitTime = 1_000, int $maxAttempts = 1_000, ): void { diff --git a/src/TBSL.php b/src/TBSL.php index 97c99bc..9ae3eb6 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -9,6 +9,7 @@ use Infocyph\UID\Configuration\TBSLConfig; use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Enums\IdOutputType; +use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\UIDException; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; @@ -107,7 +108,12 @@ public static function parse(string $tbsl): array return $data; } - $storeData = base_convert(substr($tbsl, 0, 15), 16, 10); + $storeBytes = hex2bin('0' . substr($tbsl, 0, 15)); + $storeBytes !== false || throw new Exception('Unable to parse TBSL timestamp'); + $storeParts = unpack('Jvalue', $storeBytes); + $storeValue = $storeParts['value'] ?? null; + is_int($storeValue) || throw new Exception('Unable to parse TBSL timestamp'); + $storeData = str_pad((string) $storeValue, 18, '0', STR_PAD_LEFT); $data['time'] = new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)); $data['machineId'] = (int) substr($storeData, -2); @@ -182,13 +188,25 @@ private static function generateInternal( $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence); } + [$timeSequence, $tail] = self::resolveTail( + $machineId, + $sequenced, + $timeSequence, + $clockBackwardPolicy, + $sequenceProvider, + ); self::$lastTimeSequence = $timeSequence; - $storeData = base_convert($timeSequence . sprintf('%02d', $machineId), 10, 16); + $storeValue = (int) ($timeSequence . sprintf('%02d', $machineId)); + $storeData = ltrim(bin2hex(pack('J', $storeValue)), '0'); + if (strlen($storeData) > 15) { + throw new UIDException('TBSL timestamp exceeds its 60-bit field'); + } + $id = strtoupper(sprintf( '%015s%05s', $storeData, - substr(self::sequencedGenerate($machineId, $sequenced, $timeSequence, $sequenceProvider), 0, 5), + $tail, )); return self::formatOutput($id, $outputType); @@ -213,19 +231,47 @@ private static function hexToDecimal(string $hex): int * @param int $machineId Machine identifier. * @param bool $enableSequence Whether to enable sequence. * @param int $timeSequence The timestamp sequence. - * @return string Hexadecimal sequence. + * @return array{0:int, 1:string} * @throws Exception */ - private static function sequencedGenerate( + private static function resolveTail( int $machineId, bool $enableSequence, int $timeSequence, + ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, - ): string { - return match ($enableSequence) { - true => dechex(self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider)), - default => bin2hex(random_bytes(3)), - }; + ): array { + if (!$enableSequence) { + return [$timeSequence, substr(bin2hex(random_bytes(3)), 0, 5)]; + } + + do { + try { + $sequence = self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider); + } catch (SequenceTimestampException $exception) { + if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { + throw new UIDException( + 'Clock moved backwards while generating TBSL ID', + 0, + $exception, + ); + } + + $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp); + + continue; + } + + if ($sequence < 1) { + throw new UIDException('TBSL sequence provider must return a positive integer'); + } + + if ($sequence <= 0xfffff) { + return [$timeSequence, str_pad(dechex($sequence), 5, '0', STR_PAD_LEFT)]; + } + + $timeSequence = self::waitUntilNextTimeSequence($timeSequence); + } while (true); } private static function waitUntilNextTimeSequence(int $last): int diff --git a/src/ULID.php b/src/ULID.php index 4a61c6e..20b56ea 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -10,10 +10,11 @@ use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Exceptions\ULIDException; use Infocyph\UID\Support\BaseEncoder; -use Infocyph\UID\Support\DecimalBytes; final class ULID { + private const MAX_TIMESTAMP = 281_474_976_710_655; + private static string $encodingChars = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; private static int $encodingLength = 32; @@ -52,19 +53,19 @@ public static function fromBytes(string $bytes): string throw new ULIDException('ULID binary data must be exactly 16 bytes'); } - $decimal = DecimalBytes::fromBytes($bytes); - - $encoded = str_repeat('0', 26); - $chars = str_split($encoded); - for ($index = 25; $index >= 0; --$index) { - $remainder = (int) bcmod($decimal, '32'); - $chars[$index] = self::$encodingChars[$remainder]; - $decimal = bcdiv($decimal, '32', 0); + $ulid = ''; + $buffer = 0; + $bits = 2; + for ($index = 0; $index < 16; ++$index) { + $buffer = ($buffer << 8) | ord($bytes[$index]); + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + $ulid .= self::$encodingChars[($buffer >> $bits) & 31]; + $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1; + } } - $ulid = implode('', $chars); - self::isValid($ulid) || throw new ULIDException('Converted bytes produced invalid ULID'); - return $ulid; } @@ -77,11 +78,20 @@ public static function generate( ?DateTimeInterface $dateTime = null, UlidGenerationMode $mode = UlidGenerationMode::MONOTONIC, ): string { - $time = (int) ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); + $time = $dateTime === null + ? (int) floor(microtime(true) * 1000) + : (int) $dateTime->format('Uv'); + self::assertTimestamp($time); $isMonotonic = $mode === UlidGenerationMode::MONOTONIC; + if ($isMonotonic && $dateTime === null && $time < self::$lastGenTime) { + $time = self::$lastGenTime; + } + $isDuplicate = $isMonotonic && $time === self::$lastGenTime; - self::$lastGenTime = $time; + if ($isMonotonic) { + self::$lastGenTime = $time; + } $timeChars = self::encodeTime($time); if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::$randomLength) { @@ -133,22 +143,19 @@ public static function getTime(string $ulid): DateTimeImmutable throw new ULIDException('Invalid ULID string'); } - $timeChars = str_split(strrev(substr($ulid, 0, self::$timeLength))); - $time = 0; - foreach ($timeChars as $index => $char) { - $encodingIndex = strripos(self::$encodingChars, $char); - $time += ($encodingIndex * self::$encodingLength ** $index); - } - - $time = str_split((string) $time, max(1, self::$timeLength)); - $time[1] ??= '0'; - - if ($time[0] > (time() + (86400 * 365 * 10))) { - throw new ULIDException('Invalid ULID string: timestamp too large'); + for ($index = 0; $index < self::$timeLength; ++$index) { + $encodingIndex = strpos(self::$encodingChars, $ulid[$index]); + $encodingIndex !== false || throw new ULIDException('Invalid ULID character'); + $time = ($time * self::$encodingLength) + $encodingIndex; } - return new DateTimeImmutable("@$time[0].$time[1]"); + return new DateTimeImmutable( + '@' + . intdiv($time, 1000) + . '.' + . str_pad((string) (($time % 1000) * 1000), 6, '0', STR_PAD_LEFT), + ); } /** @@ -182,31 +189,29 @@ public static function toBytes(string $ulid): string throw new ULIDException('Invalid ULID string'); } - try { - return DecimalBytes::toFixedBytes(self::decodeToDecimal($ulid), 16); - } catch (\InvalidArgumentException $exception) { - throw new ULIDException('Unable to convert ULID to bytes', 0, $exception); + $bytes = ''; + $buffer = 0; + $bits = -2; + for ($index = 0; $index < 26; ++$index) { + $alphabetIndex = strpos(self::$encodingChars, $ulid[$index]); + $alphabetIndex !== false || throw new ULIDException('Invalid ULID character'); + $buffer = ($buffer << 5) | $alphabetIndex; + $bits += 5; + if ($bits >= 8) { + $bits -= 8; + $bytes .= chr(($buffer >> $bits) & 0xff); + $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1; + } } + + return $bytes; } - /** - * Decodes ULID base32 text to an arbitrary precision decimal string. - * - * @throws ULIDException - */ - private static function decodeToDecimal(string $ulid): string + private static function assertTimestamp(int $timestamp): void { - $decimal = '0'; - foreach (str_split($ulid) as $char) { - $index = strpos(self::$encodingChars, $char); - if ($index === false) { - throw new ULIDException('Invalid ULID character'); - } - - $decimal = bcadd(bcmul($decimal, '32'), (string) $index); + if ($timestamp < 0 || $timestamp > self::MAX_TIMESTAMP) { + throw new ULIDException('ULID timestamp must fit in an unsigned 48-bit integer'); } - - return $decimal; } /** @@ -217,10 +222,10 @@ private static function decodeToDecimal(string $ulid): string private static function encodeTime(int $time): string { $timeChars = ''; - for ($i = self::$timeLength - 1; $i >= 0; $i--) { + for ($i = self::$timeLength - 1; $i >= 0; --$i) { $mod = $time % self::$encodingLength; $timeChars = self::$encodingChars[$mod] . $timeChars; - $time = ($time - $mod) / self::$encodingLength; + $time = intdiv($time, self::$encodingLength); } return $timeChars; @@ -256,8 +261,18 @@ private static function randomCharsFromState(): string */ private static function resetRandomState(): void { - for ($index = 0; $index < self::$randomLength; $index++) { - self::$lastRandChars[$index] = random_int(0, 31); + $random = random_bytes(10); + $buffer = 0; + $bits = 0; + $stateIndex = 0; + for ($index = 0; $index < 10; ++$index) { + $buffer = ($buffer << 8) | ord($random[$index]); + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + self::$lastRandChars[$stateIndex++] = ($buffer >> $bits) & 31; + $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1; + } } } @@ -265,7 +280,7 @@ private static function waitForNextMillisecond(int $lastTimestamp): int { do { usleep(1000); - $next = (int) (new DateTimeImmutable('now'))->format('Uv'); + $next = (int) floor(microtime(true) * 1000); } while ($next <= $lastTimestamp); return $next; diff --git a/src/UUID.php b/src/UUID.php index e3527a7..eb349e1 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -14,6 +14,10 @@ final class UUID { + private const MAX_V7_NODE_STATES = 1024; + + private const MAX_V7_TIMESTAMP = 281_474_976_710_655; + /** @var array */ private static array $nsList = [ 'dns' => 0, @@ -157,13 +161,20 @@ public static function isNil(string $uuid): bool } /** - * Check if UUID is valid (validates version 1-9 & NIL) + * Checks whether a UUID uses a defined RFC 9562 version/variant, NIL, or MAX. * * @param string $uuid The UUID to be checked */ public static function isValid(string $uuid): bool { - return (bool) preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-\d[0-9a-f]{3}-[089a-e][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid); + if (strcasecmp($uuid, self::nil()) === 0 || strcasecmp($uuid, self::max()) === 0) { + return true; + } + + return preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iD', + $uuid, + ) === 1; } /** @@ -225,6 +236,10 @@ public static function parse(string $uuid): array return $data; } + if (strcasecmp($uuid, self::nil()) === 0 || strcasecmp($uuid, self::max()) === 0) { + return $data; + } + $uuidData = explode('-', $uuid); if (count($uuidData) !== 5) { return $data; @@ -392,7 +407,13 @@ public static function v6(?string $node = null): string */ public static function v7(?DateTimeInterface $dateTime = null, ?string $node = null): string { - $unixTsMs = (int) ($dateTime ?? new DateTimeImmutable('now'))->format('Uv'); + $unixTsMs = $dateTime === null + ? (int) floor(microtime(true) * 1000) + : (int) $dateTime->format('Uv'); + if ($unixTsMs < 0 || $unixTsMs > self::MAX_V7_TIMESTAMP) { + throw new UUIDException('UUID v7 timestamp must fit in an unsigned 48-bit integer'); + } + $isExplicitTimestamp = $dateTime !== null; $node = $node === null ? null : self::normalizeNode($node); @@ -544,10 +565,11 @@ private static function hexToDecimal(string $hex): string return '0'; } - foreach (str_split($hex) as $char) { + $length = strlen($hex); + for ($index = 0; $index < $length; ++$index) { $decimal = bcadd( bcmul($decimal, '16'), - (string) hexdec($char), + (string) hexdec($hex[$index]), ); } @@ -561,28 +583,39 @@ private static function hexToDecimal(string $hex): string */ private static function incrementHexCounter(string $hex): ?string { - $chars = str_split(strtolower($hex)); - $hexChars = '0123456789abcdef'; - $nextNibbles = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; - for ($index = count($chars) - 1; $index >= 0; --$index) { - $value = strpos($hexChars, $chars[$index]); - if ($value === false) { - return null; - } - - if ($value === 15) { - $chars[$index] = '0'; + $hex = strtolower($hex); + for ($index = strlen($hex) - 1; $index >= 0; --$index) { + if ($hex[$index] === 'f') { + $hex[$index] = '0'; continue; } - $nextNibble = $nextNibbles[$value] ?? null; - if ($nextNibble === null) { + $next = match ($hex[$index]) { + '0' => '1', + '1' => '2', + '2' => '3', + '3' => '4', + '4' => '5', + '5' => '6', + '6' => '7', + '7' => '8', + '8' => '9', + '9' => 'a', + 'a' => 'b', + 'b' => 'c', + 'c' => 'd', + 'd' => 'e', + 'e' => 'f', + default => null, + }; + if ($next === null) { return null; } - $chars[$index] = $nextNibble; - return implode('', $chars); + $hex[$index] = $next; + + return $hex; } return null; @@ -594,7 +627,7 @@ private static function incrementHexCounter(string $hex): ?string private static function nameBased(string $namespace, string $string, int $version, string $algorithm): string { $resolvedNamespace = self::nsResolve($namespace); - if (!$resolvedNamespace) { + if ($resolvedNamespace === '') { throw new UUIDException('Invalid NameSpace!'); } @@ -655,6 +688,11 @@ private static function nextV7NodeState(string $node, int $unixTsMs, bool $isExp if ($state === null || $unixTsMs > $state['timestamp']) { $randomPart = self::randomV7NodePart(); + if ($state === null && count(self::$v7NodeState) >= self::MAX_V7_NODE_STATES) { + $oldestKey = array_key_first(self::$v7NodeState); + unset(self::$v7NodeState[$oldestKey]); + } + self::$v7NodeState[$stateKey] = ['timestamp' => $unixTsMs, 'random' => $randomPart]; return [$unixTsMs, $randomPart]; @@ -690,7 +728,7 @@ private static function nextV7Timestamp(int $lastTimestamp): int { do { usleep(1000); - $next = (int) (new DateTimeImmutable('now'))->format('Uv'); + $next = (int) floor(microtime(true) * 1000); } while ($next <= $lastTimestamp); return $next; @@ -758,15 +796,13 @@ private static function nsResolve(string $namespace): string */ private static function output(int $version, string $id): string { - $string = str_split($id, 4); - return sprintf( "%08s-%04s-$version%03s-%04x-%012s", - $string[0] . $string[1], - $string[2], - substr($string[3], 1, 3), - hexdec($string[4]) & 0x3fff | 0x8000, - $string[5] . $string[6] . $string[7], + substr($id, 0, 8), + substr($id, 8, 4), + substr($id, 13, 3), + hexdec(substr($id, 16, 4)) & 0x3fff | 0x8000, + substr($id, 20, 12), ); } diff --git a/src/Value/UuidValue.php b/src/Value/UuidValue.php index 1084c12..d7ee5fc 100644 --- a/src/Value/UuidValue.php +++ b/src/Value/UuidValue.php @@ -23,6 +23,9 @@ public function __construct(string $value) { $this->value = UUID::normalize($value); $this->parsed = UUID::parse($this->value); + if (!$this->parsed['isValid']) { + throw new \InvalidArgumentException('Invalid UUID string'); + } } public function __toString(): string diff --git a/src/XID.php b/src/XID.php index 431e652..621a8a3 100644 --- a/src/XID.php +++ b/src/XID.php @@ -49,7 +49,7 @@ public static function generate(): string public static function isValid(string $xid): bool { - return preg_match('/^[0-9a-v]{20}$/', $xid) === 1; + return preg_match('/^[01][0-9a-v]{19}$/D', $xid) === 1; } /** diff --git a/src/functions.php b/src/functions.php index 7923b1e..df702b3 100644 --- a/src/functions.php +++ b/src/functions.php @@ -22,30 +22,21 @@ if (!function_exists('__uid_base_call')) { function __uid_base_call(string $family, string $method, string $value, int $base): string { - /** @var array> $operations */ - $operations = [ - 'toBase' => [ - 'uuid' => UUID::toBase(...), - 'ulid' => ULID::toBase(...), - 'randflake' => Randflake::toBase(...), - 'snowflake' => Snowflake::toBase(...), - 'sonyflake' => Sonyflake::toBase(...), - 'tbsl' => TBSL::toBase(...), - ], - 'fromBase' => [ - 'uuid' => UUID::fromBase(...), - 'ulid' => ULID::fromBase(...), - 'randflake' => Randflake::fromBase(...), - 'snowflake' => Snowflake::fromBase(...), - 'sonyflake' => Sonyflake::fromBase(...), - 'tbsl' => TBSL::fromBase(...), - ], - ]; - - $familyHandlers = $operations[$method] ?? throw new InvalidArgumentException('Unsupported base operation'); - $handler = $familyHandlers[$family] ?? throw new InvalidArgumentException('Unsupported ID family'); - - return $handler($value, $base); + return match ($method . ':' . $family) { + 'toBase:uuid' => UUID::toBase($value, $base), + 'toBase:ulid' => ULID::toBase($value, $base), + 'toBase:randflake' => Randflake::toBase($value, $base), + 'toBase:snowflake' => Snowflake::toBase($value, $base), + 'toBase:sonyflake' => Sonyflake::toBase($value, $base), + 'toBase:tbsl' => TBSL::toBase($value, $base), + 'fromBase:uuid' => UUID::fromBase($value, $base), + 'fromBase:ulid' => ULID::fromBase($value, $base), + 'fromBase:randflake' => Randflake::fromBase($value, $base), + 'fromBase:snowflake' => Snowflake::fromBase($value, $base), + 'fromBase:sonyflake' => Sonyflake::fromBase($value, $base), + 'fromBase:tbsl' => TBSL::fromBase($value, $base), + default => throw new InvalidArgumentException('Unsupported base operation or ID family'), + }; } } @@ -417,23 +408,19 @@ function snowflake_is_valid(string $id): bool if (!function_exists('randflake_is_valid')) { function randflake_is_valid(string $id): bool { - return __uid_is_valid('randflake', $id); + return Randflake::isValid($id); } } if (!function_exists('sonyflake_is_valid')) { function sonyflake_is_valid(string $id): bool { - return __uid_is_valid('sonyflake', $id); + return Sonyflake::isValid($id); } } if (!function_exists('tbsl_is_valid')) { function tbsl_is_valid(string $id): bool { - if ($id === '') { - return false; - } - - return __uid_is_valid('tbsl', $id); + return TBSL::isValid($id); } } @@ -476,17 +463,13 @@ function randflake_from_base(string $encoded, int $base): string if (!function_exists('sonyflake_to_base')) { function sonyflake_to_base(string $id, int $base): string { - $family = 'sonyflake'; - - return __uid_base_call($family, 'toBase', $id, $base); + return Sonyflake::toBase($id, $base); } } if (!function_exists('sonyflake_from_base')) { function sonyflake_from_base(string $encoded, int $base): string { - $method = 'fromBase'; - - return __uid_base_call('sonyflake', $method, $encoded, $base); + return Sonyflake::fromBase($encoded, $base); } } diff --git a/tests/AdditionalIdsTest.php b/tests/AdditionalIdsTest.php index 75695f9..499f850 100644 --- a/tests/AdditionalIdsTest.php +++ b/tests/AdditionalIdsTest.php @@ -1,5 +1,7 @@ OpaqueId::random(0))->toThrow(\InvalidArgumentException::class) - ->and(fn () => OpaqueId::random(-1))->toThrow(\InvalidArgumentException::class); + ->and(fn () => OpaqueId::random(-1))->toThrow(\InvalidArgumentException::class) + ->and(fn () => OpaqueId::random(1025))->toThrow(\InvalidArgumentException::class); }); test('IdComparator sorts numeric and lexical values', function () { @@ -49,3 +52,25 @@ expect($sortedNumeric)->toBe(['1', '2', '10']) ->and($sortedLexical)->toBe(['a', 'b', 'c']); }); + +test('KSUID and XID reject text values outside their binary ranges', function () { + expect(KSUID::isValid(str_repeat('z', 27)))->toBeFalse() + ->and(fn () => KSUID::toBytes(str_repeat('z', 27)))->toThrow(\Exception::class) + ->and(XID::isValid('2' . str_repeat('0', 19)))->toBeFalse(); +}); + +test('KSUID rejects timestamps outside its unsigned 32-bit lifetime', function () { + expect(fn () => KSUID::generate(new DateTimeImmutable('@1399999999'))) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => KSUID::generate(new DateTimeImmutable('@5694967296'))) + ->toThrow(\InvalidArgumentException::class); +}); + +test('Deterministic IDs enforce canonical namespace and output bounds', function () { + expect(fn () => DeterministicId::fromPayload('payload', 0)) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => DeterministicId::fromPayload('payload', 44)) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => DeterministicId::fromPayload('payload', 24, 'invalid|namespace')) + ->toThrow(\InvalidArgumentException::class); +}); diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php index 93f1b93..fe4d2fc 100644 --- a/tests/IdFactoryTest.php +++ b/tests/IdFactoryTest.php @@ -1,5 +1,7 @@ */ + private array $store = []; - $id1 = Snowflake::generate(1, 1); - $id2 = Snowflake::generate(1, 1); + public function clear(): bool + { + $this->store = []; - expect((int)$id2)->toBeGreaterThan((int)$id1); -}); + return true; + } -test('custom callback sequence provider works', function () { - $counter = 0; - Snowflake::useSequenceCallback(function () use (&$counter): int { - $counter++; - return $counter; - }); + public function delete(string $key): bool + { + unset($this->store[$key]); - $id1 = Snowflake::generate(0, 0); - $id2 = Snowflake::generate(0, 0); + return true; + } - expect($id1)->not()->toBe($id2); -}); + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete((string) $key); + } -test('psr-16 sequence provider works', function () { - $cache = new class implements CacheInterface { - /** @var array */ - private array $store = []; + return true; + } - public function get(string $key, mixed $default = null): mixed - { - return $this->store[$key] ?? $default; - } + public function get(string $key, mixed $default = null): mixed + { + return $this->store[$key] ?? $default; + } - public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool - { - unset($ttl); - $this->store[$key] = $value; - return true; + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->get($key, $default); } - public function delete(string $key): bool - { - unset($this->store[$key]); - return true; - } + return $values; + } - public function clear(): bool - { - $this->store = []; - return true; - } + public function has(string $key): bool + { + return isset($this->store[$key]); + } - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - $values = []; - foreach ($keys as $key) { - $values[$key] = $this->get($key, $default); - } + public function seed(string $key, mixed $value): void + { + $this->store[$key] = $value; + } - return $values; + public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + { + $this->lastTtl = $ttl; + if ($this->failWrites) { + return false; } - public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool - { - foreach ($values as $key => $value) { - $this->set((string)$key, $value, $ttl); - } + $this->store[$key] = $value; - return true; - } + return true; + } - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - $this->delete((string)$key); + public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + if (!$this->set((string) $key, $value, $ttl)) { + return false; } - - return true; } - public function has(string $key): bool - { - return array_key_exists($key, $this->store); + return true; + } +} + +final class FutureOnceSequenceProvider implements SequenceProviderInterface +{ + public int $calls = 0; + + public function next(string $type, int $machineId, int $timestamp): int + { + unset($type, $machineId); + ++$this->calls; + + if ($this->calls === 1) { + throw new \Infocyph\UID\Exceptions\SequenceTimestampException($timestamp + 1, $timestamp); } - }; - Snowflake::setSequenceProvider(new PsrSimpleCacheSequenceProvider($cache)); + return 1; + } +} - $id1 = Snowflake::generate(2, 3); - $id2 = Snowflake::generate(2, 3); +beforeEach(function () { + Snowflake::resetSequenceProvider(); +}); - expect((int)$id2)->toBeGreaterThan((int)$id1); +afterEach(function () { + Snowflake::resetSequenceProvider(); }); -test('psr-16 sequence provider supports custom synchronizer callback', function () { - $cache = new class implements CacheInterface { - /** @var array */ - private array $store = []; +test('in-memory sequence provider works', function () { + Snowflake::useInMemorySequenceProvider(); - public function get(string $key, mixed $default = null): mixed - { - return $this->store[$key] ?? $default; - } + $id1 = Snowflake::generate(1, 1); + $id2 = Snowflake::generate(1, 1); - public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool - { - unset($ttl); - $this->store[$key] = $value; + expect((int)$id2)->toBeGreaterThan((int)$id1); +}); - return true; - } +test('in-memory sequence provider rejects a timestamp rollback', function () { + $provider = new InMemorySequenceProvider(); + $provider->next('audit', 1, 2); - public function delete(string $key): bool - { - unset($this->store[$key]); + expect(fn () => $provider->next('audit', 1, 1)) + ->toThrow(\Infocyph\UID\Exceptions\SequenceTimestampException::class); +}); - return true; - } +test('Snowflake retries a timestamp sampled before another writer advances state', function () { + $provider = new FutureOnceSequenceProvider(); + Snowflake::setSequenceProvider($provider); - public function clear(): bool - { - $this->store = []; + expect(Snowflake::isValid(Snowflake::generate()))->toBeTrue() + ->and($provider->calls)->toBe(2); +}); - return true; - } +test('custom callback sequence provider works', function () { + $counter = 0; + Snowflake::useSequenceCallback(function () use (&$counter): int { + $counter++; + return $counter; + }); - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - $values = []; - foreach ($keys as $key) { - $values[$key] = $this->get($key, $default); - } + $id1 = Snowflake::generate(0, 0); + $id2 = Snowflake::generate(0, 0); - return $values; - } + expect($id1)->not()->toBe($id2); +}); - public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool - { - foreach ($values as $key => $value) { - $this->set((string) $key, $value, $ttl); - } +test('psr-16 sequence provider works', function () { + $cache = new SequenceTestCache(); - return true; - } + Snowflake::setSequenceProvider(new PsrSimpleCacheSequenceProvider($cache)); - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - $this->delete((string) $key); - } + $id1 = Snowflake::generate(2, 3); + $id2 = Snowflake::generate(2, 3); - return true; - } + expect((int)$id2)->toBeGreaterThan((int)$id1); +}); - public function has(string $key): bool - { - return array_key_exists($key, $this->store); - } - }; +test('psr-16 sequence provider supports custom synchronizer callback', function () { + $cache = new SequenceTestCache(); $synchronizerCalls = 0; $synchronizer = function (string $key, callable $criticalSection) use (&$synchronizerCalls): int { - expect($key)->toContain('uid:seq:'); + expect($key)->toContain('uid.seq.'); $synchronizerCalls++; return $criticalSection(); @@ -187,3 +183,44 @@ public function has(string $key): bool expect((int) $id2)->toBeGreaterThan((int) $id1) ->and($synchronizerCalls)->toBe(2); }); + +test('psr-16 sequence provider fails safely when state cannot be persisted', function () { + $cache = new SequenceTestCache(); + $cache->failWrites = true; + $provider = new PsrSimpleCacheSequenceProvider($cache); + + expect(fn() => $provider->next('snowflake', 1, 1)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); +}); + +test('psr-16 sequence provider rejects malformed and future state', function () { + $cache = new SequenceTestCache(); + $provider = new PsrSimpleCacheSequenceProvider($cache); + $cache->seed('uid.seq.snowflake.1', 'invalid'); + + expect(fn() => $provider->next('snowflake', 1, 1)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); + + $cache->seed('uid.seq.snowflake.1', ['timestamp' => 2, 'sequence' => 1]); + + expect(fn () => $provider->next('snowflake', 1, 1)) + ->toThrow(\Infocyph\UID\Exceptions\SequenceTimestampException::class); +}); + +test('filesystem sequence provider rejects unsafe keys and corrupted state', function () { + $type = 'audit' . bin2hex(random_bytes(4)); + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "uid-$type-1.seq"; + file_put_contents($path, 'corrupted'); + $provider = new FilesystemSequenceProvider(); + + try { + expect(fn () => $provider->next('../escape', 1, 1)) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => $provider->next($type, 1, 1)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); + } finally { + if (file_exists($path)) { + unlink($path); + } + } +}); diff --git a/tests/ShortIdTest.php b/tests/ShortIdTest.php index bb26b8b..bd4c9cb 100644 --- a/tests/ShortIdTest.php +++ b/tests/ShortIdTest.php @@ -1,5 +1,7 @@ toBeString() ->not()->toBeEmpty() ->toHaveLength(24) - ->toMatch('/^[0-9a-z]+$/'); + ->toMatch('/^[a-z][0-9a-z]+$/'); }); test('CUID2 custom length', function () { @@ -45,3 +47,12 @@ ->and(nanoid_is_valid($nano, 12))->toBeTrue() ->and(cuid2_is_valid($cuid))->toBeTrue(); }); + +test('CUID2 uses canonical first-letter and length boundaries', function () { + $minimum = CUID2::generate(2); + + expect($minimum)->toMatch('/^[a-z][0-9a-z]$/') + ->and(CUID2::isValid('1abc'))->toBeFalse() + ->and(fn () => CUID2::generate(1))->toThrow(\InvalidArgumentException::class) + ->and(fn () => CUID2::generate(33))->toThrow(\InvalidArgumentException::class); +}); diff --git a/tests/SnowflakeTest.php b/tests/SnowflakeTest.php index 9ef2a86..25eba0f 100644 --- a/tests/SnowflakeTest.php +++ b/tests/SnowflakeTest.php @@ -1,5 +1,7 @@ and($binaryId)->toBeString() ->and(strlen($binaryId))->toBe(8); }); + +test('Snowflake config rejects invalid epochs and resolver output', function () { + $invalidResolver = new SnowflakeConfig(nodeResolver: fn (): string => 'invalid'); + $futureEpoch = ((int) floor(microtime(true) * 1000)) + 60_000; + + expect(fn () => Snowflake::generateWithConfig(new SnowflakeConfig(customEpoch: 'not-a-date'))) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => Snowflake::generateWithConfig(new SnowflakeConfig(customEpoch: $futureEpoch))) + ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class) + ->and(fn () => Snowflake::generateWithConfig($invalidResolver)) + ->toThrow(\UnexpectedValueException::class); +}); + +test('Snowflake validation rejects values outside the signed 63-bit layout', function () { + expect(Snowflake::isValid((string) PHP_INT_MAX))->toBeTrue() + ->and(Snowflake::isValid('9223372036854775808'))->toBeFalse() + ->and(fn () => Snowflake::parse('9223372036854775808')) + ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class); +}); diff --git a/tests/SonyflakeTest.php b/tests/SonyflakeTest.php index 9607929..ba68ae0 100644 --- a/tests/SonyflakeTest.php +++ b/tests/SonyflakeTest.php @@ -1,5 +1,7 @@ and($binaryId)->toBeString() ->and(strlen($binaryId))->toBe(8); }); + +test('Sonyflake config rejects invalid epochs and resolver output', function () { + $invalidResolver = new SonyflakeConfig(machineIdResolver: fn (): string => '1'); + $futureEpoch = ((int) floor(microtime(true) * 1000)) + 60_000; + + expect(fn () => Sonyflake::generateWithConfig(new SonyflakeConfig(customEpoch: 'not-a-date'))) + ->toThrow(\InvalidArgumentException::class) + ->and(fn () => Sonyflake::generateWithConfig(new SonyflakeConfig(customEpoch: $futureEpoch))) + ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class) + ->and(fn () => Sonyflake::generateWithConfig($invalidResolver)) + ->toThrow(\UnexpectedValueException::class); +}); + +test('Sonyflake validation rejects values outside the signed 63-bit layout', function () { + expect(Sonyflake::isValid((string) PHP_INT_MAX))->toBeTrue() + ->and(Sonyflake::isValid('9223372036854775808'))->toBeFalse() + ->and(fn () => Sonyflake::parse('9223372036854775808')) + ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class); +}); diff --git a/tests/TBSLTest.php b/tests/TBSLTest.php index a893e24..993e7be 100644 --- a/tests/TBSLTest.php +++ b/tests/TBSLTest.php @@ -1,5 +1,7 @@ toBeString() ->and(strlen($binary))->toBe(10); }); + +test('TBSL advances time when a sequence exceeds its 20-bit field', function () { + $firstTimestamp = null; + + TBSL::useSequenceCallback(function (string $type, int $machineId, int $timestamp) use (&$firstTimestamp): int { + unset($type, $machineId); + $firstTimestamp ??= $timestamp; + + return $timestamp === $firstTimestamp ? 0x100000 : 1; + }); + + try { + $id = TBSL::generate(0, true); + expect(substr($id, -5))->toBe('00001'); + } finally { + TBSL::resetSequenceProvider(); + } +}); diff --git a/tests/ULIDTest.php b/tests/ULIDTest.php index f526b2b..0e24e01 100644 --- a/tests/ULIDTest.php +++ b/tests/ULIDTest.php @@ -1,5 +1,7 @@ toBe($ulid); }); + +test('ULID binary boundary vectors are canonical', function () { + $zero = str_repeat("\0", 16); + $maximum = str_repeat("\xff", 16); + + expect(ULID::fromBytes($zero))->toBe(str_repeat('0', 26)) + ->and(ULID::fromBytes($maximum))->toBe('7' . str_repeat('Z', 25)) + ->and(ULID::toBytes('7' . str_repeat('Z', 25)))->toBe($maximum); +}); + +test('ULID rejects timestamps outside its unsigned 48-bit field', function () { + expect(fn () => ULID::generate(new DateTimeImmutable('@-1'))) + ->toThrow(ULIDException::class) + ->and(fn () => ULID::generate(new DateTimeImmutable('@281474976711'))) + ->toThrow(ULIDException::class); +}); diff --git a/tests/UUIDTest.php b/tests/UUIDTest.php index a1d611b..0cedac9 100644 --- a/tests/UUIDTest.php +++ b/tests/UUIDTest.php @@ -1,5 +1,7 @@ and($parsed['version'])->toBe(7) ->and(UUID::fromBytes($bytes))->toBe(strtolower($uuid)); }); + +test('UUID validation rejects reserved versions and non-RFC variants', function () { + expect(UUID::isValid('00000000-0000-9000-8000-000000000000'))->toBeFalse() + ->and(UUID::isValid('00000000-0000-4000-c000-000000000000'))->toBeFalse() + ->and(UUID::isValid(UUID::nil()))->toBeTrue() + ->and(UUID::isValid(UUID::max()))->toBeTrue(); +}); + +test('UUID v7 rejects timestamps outside its unsigned 48-bit field', function () { + expect(fn () => UUID::v7(new DateTimeImmutable('@-1'))) + ->toThrow(\Infocyph\UID\Exceptions\UUIDException::class) + ->and(fn () => UUID::v7(new DateTimeImmutable('@281474976711'))) + ->toThrow(\Infocyph\UID\Exceptions\UUIDException::class); +});