diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml new file mode 100644 index 0000000..f4f1239 --- /dev/null +++ b/.github/workflows/release-acceptance.yml @@ -0,0 +1,50 @@ +name: UID 6 Release Acceptance + +on: + workflow_dispatch: + push: + branches: ["main", "master"] + paths: + - "src/**" + - "tests/**" + - "composer.json" + - ".github/workflows/release-acceptance.yml" + pull_request: + branches: ["main", "master"] + paths: + - "src/**" + - "tests/**" + - "composer.json" + - ".github/workflows/release-acceptance.yml" + +permissions: + contents: read + +jobs: + production-smoke: + name: Production smoke (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ["8.4", "8.5"] + steps: + - name: Checkout exact candidate + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: ctype + coverage: none + + - name: Install production dependencies + run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + + - name: Check every generator and format in one 100-cycle pass + run: php tests/smoke.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 7f4e52b..e8fccac 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,19 +5,29 @@ on: - cron: "0 0 * * 0" push: branches: [ "main", "master" ] + tags: [ "v*", "[0-9]*" ] pull_request: branches: [ "main", "master", "develop", "development" ] jobs: phpforge: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') uses: infocyph/phpforge/.github/workflows/security-standards.yml@main with: fail_on_skipped_tests: true integration_services: '[]' service_topologies: '{}' - php_extensions: '["bcmath"]' + php_extensions: '["ctype", "pcntl"]' permissions: security-events: write actions: read contents: read + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: infocyph/phpforge/.github/workflows/release.yml@main + permissions: + contents: write + secrets: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + diff --git a/.gitignore b/.gitignore index 6991d72..09a9f86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,20 @@ -.idea -.psalm-cache -.phpunit.cache -.vscode -.windsurf -.codex +/.idea +/.psalm-cache +/.phpunit.cache +/.vscode +/.windsurf +/.codex *~ *.patch *.txt !docs/requirements.txt -AI_CONTEXT.md composer.lock example example.php -git-story_media +/git-story_media patch.php test.php -var -vendor +/var +/vendor d2utmp* +/graphify-out diff --git a/README.md b/README.md index e2810f3..9c1b0a2 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,16 @@ All-in-one unique ID toolkit for PHP. - TypeID, ObjectID, NanoID, RandomId, CUID2, KSUID, XID - Opaque and deterministic IDs - Value objects and comparator utilities -- Binary conversion and base encoders (`16`, `32`, `36`, `58`, `62`) +- Binary conversion and base encoders (`10`, `16`, `32`, `36`, `58`, `62`) - Pluggable sequence providers (filesystem, memory, PSR-16 cache, callback) +- Explicit legacy/upstream Sonyflake and Randflake compatibility modes +- Optional PSR-20 clocks and Runwire 2.1.1+ cooperative wait integration ## Requirements -- PHP `>=8.2` +- PHP `>=8.4` - A 64-bit PHP runtime +- PHP ctype extension ## Installation @@ -75,7 +78,7 @@ $decoded = UUID::fromBase($base58, 58); ``` The shared byte-level encoder is available as -`Infocyph\UID\Support\BaseEncoder` for bases `16`, `32`, `36`, `58`, and `62`. +`Infocyph\UID\Support\BaseEncoder` for bases `10`, `16`, `32`, `36`, `58`, and `62`. ## References @@ -98,6 +101,10 @@ UID is protected by [PHPForge](https://github.com/infocyph/PHPForge), an automat tests, static and taint analysis, dependency auditing, architecture checks, and release readiness. Automated controls reduce risk but do not replace responsible disclosure or manual review. +Release acceptance also runs `php tests/smoke.php` with a production-only install on PHP 8.4 and 8.5. +One 100-cycle pass covers every generator and supported format, checking valid output, sample uniqueness, +and relevant round trips. Long HTTP benchmarks and soak runs are not release gates. + ---
diff --git a/TBSL.md b/TBSL.md index 3b742ec..fac9aa6 100644 --- a/TBSL.md +++ b/TBSL.md @@ -23,50 +23,53 @@ Character positions are 1-based. | Characters | Length | Field | Description | |:--|--:|:--|:--| -| 1-15 | 15 hex chars | Time-machine payload | Uppercase hexadecimal encoding of the decimal payload `SSSSSSSSSSUUUUUUMM`, left-padded with zeroes to 15 characters. | -| 16-20 | 5 hex chars | Entropy or sequence | Random suffix by default, or a sequence suffix when sequenced mode is enabled. | +| 1-15 | 15 hex chars | Time-machine payload | Uppercase hexadecimal encoding of the 60-bit integer `(unixMicroseconds * 100) + machineId`, left-padded with zeroes to 15 characters. | +| 16-20 | 5 hex chars | Sequence or entropy | Zero-based sequence suffix by default, or random entropy when sequenced mode is disabled. | -The decimal payload is composed as follows: +The time-machine payload is numeric, not a fixed-width decimal string. Parsing is: -| Decimal digits | Length | Field | Description | -|:--|--:|:--|:--| -| 1-10 | 10 digits | Unix seconds | Seconds since the Unix epoch. | -| 11-16 | 6 digits | Microseconds | Microsecond fraction of the current second. | -| 17-18 | 2 digits | Machine ID | Machine identifier from `00` to `99`. | +```text +unixMicroseconds = intdiv(payload, 100) +machineId = payload % 100 +``` + +This representation remains valid when Unix seconds grow beyond ten decimal +digits, as long as the combined value still fits the 60-bit field. ## Generation Generation accepts: - `machineId`: integer from `0` to `99`; default is `0`. -- `sequenced`: boolean; default is `false`. +- `sequenced`: boolean; default is `true`. Use `generateRandom()` or `sequenced: false` for the entropy suffix. The generator: 1. Reads the current Unix time with microsecond precision. -2. Builds the decimal time sequence as `seconds + microseconds`. -3. Appends the two-digit machine ID to form `SSSSSSSSSSUUUUUUMM`. -4. Converts that decimal payload to hexadecimal and left-pads it to 15 - characters. +2. Converts that time to an integer count of Unix microseconds. +3. Forms the 60-bit time-machine payload as `(unixMicroseconds * 100) + machineId`. +4. Converts that integer to hexadecimal and left-pads it to 15 characters. 5. Appends a 5-character hexadecimal suffix: - - random mode: first 5 hex characters from 3 random bytes; - - sequenced mode: the next sequence value for the - `(type = "tbsl", machineId, timestamp)` key, encoded as hex and padded to - 5 characters. + - sequenced mode (default): obtains a positive provider allocation from + `(type = "tbsl", machineId, timestamp)`, then encodes + `allocation - 1` as five hexadecimal characters; + - random mode: first 5 hex characters from 3 random bytes. 6. Returns the 20-character uppercase hexadecimal string. -Sequence providers should keep returned sequence values within the 20-bit suffix -range, `0x00000` through `0xFFFFF`. +In sequenced mode, provider allocations `1..0x100000` map to encoded suffixes +`00000..FFFFF`. If that range is exhausted for one timestamp, generation waits +for the next usable timestamp or fails according to the configured bounded-wait +and clock-backward policy. ## Parsing To parse a canonical TBSL value: 1. Validate the string against `^[0-9A-F]{20}$`. -2. Decode characters `1-15` from hexadecimal to the decimal payload. -3. Read the first 10 decimal digits as Unix seconds. -4. Read the next 6 decimal digits as microseconds. -5. Read the final 2 decimal digits as the machine ID. +2. Decode characters `1-15` from hexadecimal to the 60-bit payload. +3. Recover Unix microseconds with `intdiv(payload, 100)`. +4. Recover the machine ID with `payload % 100`. +5. Build the timestamp from the recovered seconds and microsecond fraction. The suffix is intentionally opaque. It is not needed to recover the timestamp or machine ID. @@ -80,11 +83,12 @@ move forward. For IDs generated within the same microsecond and machine ID: - random mode provides uniqueness through entropy, but not generation order; -- sequenced mode provides deterministic suffix ordering while the sequence value - remains within the 5-character hexadecimal suffix. +- sequenced mode provides deterministic suffix ordering while the sequence range + remains available. If the clock moves backward, the implementation either waits for the next usable time sequence or throws, depending on the configured clock-backward policy. +Waits are bounded; a frozen injected clock cannot spin indefinitely. ## Encodings @@ -92,6 +96,7 @@ The canonical representation is uppercase hexadecimal. The package can also convert canonical TBSL values to and from: - raw 10-byte binary; +- base10; - base16; - base32; - base36; @@ -105,7 +110,8 @@ canonical 20-character uppercase hexadecimal TBSL string. - Canonical size: 20 hex characters. - Binary size: 10 bytes. +- Time-machine payload: 60 bits. - Timestamp precision: microseconds. - Machine ID range: `0` through `99`. - Suffix size: 5 hex characters, or 20 bits. -- Maximum suffix cardinality per `(timestamp, machineId)` key: 1,048,576 values. +- Sequenced suffix cardinality per `(timestamp, machineId)` key: 1,048,576 values. diff --git a/benchmarks/BaseCodecBench.php b/benchmarks/BaseCodecBench.php index d1135f3..f424983 100644 --- a/benchmarks/BaseCodecBench.php +++ b/benchmarks/BaseCodecBench.php @@ -5,20 +5,40 @@ namespace Infocyph\UID\Benchmarks; use Infocyph\UID\Support\BaseEncoder; +use Infocyph\UID\Support\DecimalBytes; +use Infocyph\UID\Support\NumericIdCodec; +use Infocyph\UID\Support\TypeIdCodec; use PhpBench\Attributes as Bench; final class BaseCodecBench { + /** @var array */ + private array $decimal = []; + + /** @var array> */ + private array $encoded = []; + /** @var array */ private array $samples = []; + private string $typeIdEncoded; + public function __construct() { require_once __DIR__ . '/BenchBootstrap.php'; BenchBootstrap::load(); - foreach ([8, 10, 12, 16, 20, 32] as $length) { - $this->samples[$length] = random_bytes($length); + + foreach ([8, 10, 12, 16, 20, 32, 64] as $length) { + $sample = random_bytes($length); + $this->samples[$length] = $sample; + $this->decimal[$length] = DecimalBytes::fromBytes($sample); + + foreach ([10, 16, 32, 36, 58, 62] as $base) { + $this->encoded[$base][$length] = BaseEncoder::encodeBytes($sample, $base); + } } + + $this->typeIdEncoded = TypeIdCodec::encode($this->samples[16]); } #[Bench\Revs(1000), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] @@ -57,6 +77,59 @@ public function benchDecimal(array $params): void BaseEncoder::encodeBytes($this->sample($params), 10); } + #[Bench\Revs(250), Bench\Iterations(5), Bench\ParamProviders('provideBaseLengthPairs')] + public function benchDecode(array $params): void + { + BaseEncoder::decodeToBytes( + $this->encoded[$params['base']][$params['length']], + $params['base'], + $params['length'], + ); + } + + #[Bench\Revs(500), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchNumericFromBytes(array $params): void + { + NumericIdCodec::decimalFromBytes($this->sample($params), $params['length']); + } + + #[Bench\Revs(500), Bench\Iterations(5), Bench\ParamProviders('provideLengths')] + public function benchNumericToBytes(array $params): void + { + DecimalBytes::toFixedBytes($this->decimal[$params['length']], $params['length']); + } + + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchTypeIdDecode(): void + { + TypeIdCodec::decode($this->typeIdEncoded); + } + + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchTypeIdEncode(): void + { + TypeIdCodec::encode($this->samples[16]); + } + + /** + * @return array + */ + public function provideBaseLengthPairs(): array + { + $pairs = []; + + foreach ([10, 16, 32, 36, 58, 62] as $base) { + foreach ([8, 10, 12, 16, 20, 32] as $length) { + $pairs['base-' . $base . '-' . $length . '-bytes'] = [ + 'base' => $base, + 'length' => $length, + ]; + } + } + + return $pairs; + } + /** * @return array */ @@ -69,6 +142,7 @@ public function provideLengths(): array '16-bytes' => ['length' => 16], '20-bytes' => ['length' => 20], '32-bytes' => ['length' => 32], + '64-bytes' => ['length' => 64], ]; } diff --git a/benchmarks/HotspotBench.php b/benchmarks/HotspotBench.php index f50f9f1..9f0b5b9 100644 --- a/benchmarks/HotspotBench.php +++ b/benchmarks/HotspotBench.php @@ -4,6 +4,7 @@ namespace Infocyph\UID\Benchmarks; +use Closure; use Infocyph\UID\Configuration\RandflakeConfig; use Infocyph\UID\Configuration\SnowflakeConfig; use Infocyph\UID\Configuration\SonyflakeConfig; @@ -28,10 +29,14 @@ final class HotspotBench { + private Closure $cuidFingerprint; + private string $opaque; private RandflakeConfig $randflakeConfig; + private Closure $resetCuidFingerprint; + private SnowflakeConfig $snowflakeConfig; private SonyflakeConfig $sonyflakeConfig; @@ -43,6 +48,26 @@ public function __construct() require_once __DIR__ . '/BenchBootstrap.php'; BenchBootstrap::load(); + $fingerprint = Closure::bind( + static fn(): string => CUID2::fingerprint(), + null, + CUID2::class, + ); + $resetFingerprint = Closure::bind( + static function (): void { + CUID2::$fingerprint = null; + }, + null, + CUID2::class, + ); + if (!$fingerprint instanceof Closure || !$resetFingerprint instanceof Closure) { + throw new \LogicException('Unable to bind CUID2 benchmark helpers'); + } + + $this->cuidFingerprint = $fingerprint; + $this->resetCuidFingerprint = $resetFingerprint; + ($this->cuidFingerprint)(); + $provider = new FilesystemSequenceProvider(namespace: 'phpbench'); [$leaseStart, $leaseEnd, $secret] = BenchBootstrap::randflakeContext(); $this->snowflakeConfig = new SnowflakeConfig(sequenceProvider: $provider); @@ -58,6 +83,26 @@ public function benchCuid2(): void CUID2::generate(); } + #[Bench\Revs(100), Bench\Iterations(5)] + public function benchCuid2ColdGenerate(): void + { + ($this->resetCuidFingerprint)(); + CUID2::generate(); + } + + #[Bench\Revs(100), Bench\Iterations(5)] + public function benchCuid2FingerprintCold(): void + { + ($this->resetCuidFingerprint)(); + ($this->cuidFingerprint)(); + } + + #[Bench\Revs(1000), Bench\Iterations(5)] + public function benchCuid2FingerprintWarm(): void + { + ($this->cuidFingerprint)(); + } + #[Bench\Revs(1000), Bench\Iterations(5)] public function benchDeterministicId(): void { diff --git a/composer.json b/composer.json index 92e9d72..da0c90f 100644 --- a/composer.json +++ b/composer.json @@ -31,17 +31,22 @@ } ], "require": { - "php": "^8.2", - "php-64bit": "^8.2" + "php": "^8.4", + "php-64bit": "^8.4", + "ext-ctype": "*" }, "require-dev": { "infocyph/phpforge": "dev-main@dev", + "infocyph/runwire": "^2.1.1", + "psr/clock": "^1.0", "psr/simple-cache": "^3.0" }, "replace": { "abmmhasan/uuid": "self.version" }, "suggest": { + "infocyph/runwire": "Enables request-aware cooperative waits for coordinated generators.", + "psr/clock": "Enables injectable wall-clock sampling for coordinated generators.", "psr/simple-cache": "Required for the optional PSR-16 sequence provider." }, "minimum-stability": "stable", diff --git a/docs/base-encoding.rst b/docs/base-encoding.rst index a92daf9..be78b3d 100644 --- a/docs/base-encoding.rst +++ b/docs/base-encoding.rst @@ -7,6 +7,7 @@ UID exposes base conversion through algorithm-specific APIs and a shared Supported Bases --------------- +- ``10``: decimal alphabet - ``16``: lowercase hexadecimal alphabet - ``32``: ``0-9a-v`` alphabet - ``36``: ``0-9a-z`` alphabet @@ -23,6 +24,7 @@ the canonical input and restore the canonical output type: - ``ULID::toBase($ulid, $base)`` / ``ULID::fromBase($encoded, $base)`` - ``Snowflake::toBase($id, $base)`` / ``Snowflake::fromBase($encoded, $base)`` - ``Sonyflake::toBase($id, $base)`` / ``Sonyflake::fromBase($encoded, $base)`` +- ``Randflake::toBase($id, $base, $format)`` / ``Randflake::fromBase($encoded, $base, $format)`` - ``TBSL::toBase($id, $base)`` / ``TBSL::fromBase($encoded, $base)`` .. code-block:: php @@ -56,5 +58,7 @@ left-padded and validated consistently. Notes ----- +Base-10 values are also transport encodings of the underlying bytes; for structured numeric ID families, prefer the algorithm-specific canonical decimal representation when one exists. + Alternate-base values are transport encodings. They do not replace each algorithm's canonical representation. diff --git a/docs/benchmark-report.rst b/docs/benchmark-report.rst index 541ce19..af57ab5 100644 --- a/docs/benchmark-report.rst +++ b/docs/benchmark-report.rst @@ -62,9 +62,25 @@ inside it. Those measurements are not comparable algorithm baselines, so no misleading percentage delta is reported. This report is the first isolated v5 baseline; future releases should compare against it using the checked-in harness. -Correctness gates took priority over throughput: the multi-process test suite -for Snowflake, Sonyflake, Randflake, TBSL, and sequence reservations produced -zero duplicates and zero lock errors. +Current Release Checks +---------------------- + +Release acceptance runs one 100-cycle smoke pass across every generator and +supported format on PHP 8.4 and 8.5, using a production-only Composer install. +It checks valid output, no repeated IDs within each sample, relevant binary +round trips, configured node fields and deterministic results. Run it locally +with ``php tests/smoke.php``. + +The Security & Standards workflow retains the full test suite, static and taint +analysis, dependency audit, and process/fork, cancellation, exhaustion and +malformed-input regressions. The smoke pass is an additional consumer check; +it does not establish collision probability, cryptographic security or sustained +host throughput. + +The former long HTTP performance matrix and persistent-worker soak are removed. +There is no mandatory RPM comparison against tag ``5.0``. Existing component +benchmark tools remain available for targeted investigations; the historical +v5 measurements above are not v6 performance claims. Filesystem Contention --------------------- diff --git a/docs/compatibility.rst b/docs/compatibility.rst index 614790d..c3a8b84 100644 --- a/docs/compatibility.rst +++ b/docs/compatibility.rst @@ -10,8 +10,11 @@ Format Compatibility - ObjectID implements the BSON 12-byte ObjectID layout and canonical lowercase hexadecimal text. - ULID uses canonical Crockford Base32 and supports random and process-local monotonic modes. - Snowflake uses a 41/5/5/12 signed 64-bit layout. -- Sonyflake uses a 39/16/8 signed 64-bit layout with 10 millisecond timestamps. -- Randflake uses an unsigned 64-bit 30/17/17 layout before keyed permutation. +- Sonyflake defaults to UID's 39/16/8 time/machine/sequence layout and also + supports explicit upstream 39/8/16 time/sequence/machine mode. +- Randflake defaults to UID's unsigned 30/17/17 payload plus legacy Feistel + representation and also supports explicit upstream SPARX64 representation, + signed-decimal storage, and upstream Base32hex text. - TBSL is a project-specific 10-byte, uppercase hexadecimal format. - KSUID and XID retain their standard fixed-length text and binary layouts. @@ -61,6 +64,8 @@ Always enforce authorization independently of identifier format. Runtime Requirements -------------------- -- PHP 8.2 or newer on a 64-bit runtime. +- PHP 8.4 or newer on a 64-bit runtime. +- The ctype extension is required. +- Runwire 2.1.1+ and PSR-20 clocks are optional passed-instance integrations. - No BCMath dependency. - PSR-16 is optional and needed only for the PSR simple-cache sequence provider. diff --git a/docs/db-storage.rst b/docs/db-storage.rst index 6ae841d..30a4a2a 100644 --- a/docs/db-storage.rst +++ b/docs/db-storage.rst @@ -1,7 +1,7 @@ Database Storage ================ -Storage recommendations are documentation only; v5 has no runtime ``DbStorage`` API. +Storage recommendations are documentation only; UID has no runtime ``DbStorage`` API. .. list-table:: :header-rows: 1 @@ -24,9 +24,12 @@ Storage recommendations are documentation only; v5 has no runtime ``DbStorage`` * - Snowflake/Sonyflake - ``BIGINT`` - ``BIGINT`` - * - Randflake + * - Randflake (UID format) - ``BIGINT UNSIGNED`` / ``BINARY(8)`` - ``NUMERIC(20,0)`` / ``BYTEA`` + * - Randflake (upstream format) + - signed ``BIGINT`` / ``BINARY(8)`` + - signed ``BIGINT`` / ``BYTEA`` * - TBSL - ``CHAR(20)`` / ``BINARY(10)`` - ``CHAR(20)`` / ``BYTEA`` @@ -38,3 +41,5 @@ the entity type in the schema and store the underlying UUID bytes. An epoch is part of a deployed Snowflake or Sonyflake ID domain. Changing it creates a different domain and may eventually produce values overlapping the original domain. + +Randflake storage must preserve the format discriminator outside the raw value when an application can contain both UID and upstream-compatible representations. The upstream representation is signed 64-bit; the legacy UID representation can require the full unsigned 64-bit domain. diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 96bb7bb..4a60dd2 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -9,7 +9,9 @@ Hierarchy - ``Infocyph\\UID\\Exceptions\\ULIDException`` - ``Infocyph\\UID\\Exceptions\\SnowflakeException`` - ``Infocyph\\UID\\Exceptions\\SonyflakeException`` +- ``Infocyph\\UID\\Exceptions\\RandflakeException`` - ``Infocyph\\UID\\Exceptions\\FileLockException`` +- ``Infocyph\\UID\\Exceptions\\SequenceTimestampException`` (extends ``FileLockException``) Usage Pattern ------------- diff --git a/docs/framework-integration.rst b/docs/framework-integration.rst index 4cd2772..5f930b2 100644 --- a/docs/framework-integration.rst +++ b/docs/framework-integration.rst @@ -6,35 +6,82 @@ Laravel - Helper functions are available through Composer autoload. - Prefer UUIDv7 or ULID for ordered primary keys. -- Keep IDs as strings in application boundaries; convert to binary only at persistence edges. +- Keep IDs as strings at application boundaries; convert to binary only at persistence edges. Symfony ------- - Helper functions are available through Composer autoload. -- Prefer central generation through ``Infocyph\\UID\\Id`` inside services. +- Prefer central generation through Infocyph\\UID\\Id inside services. Generic PHP Apps ---------------- -- Use ``Id::nanoId()`` for short public IDs. -- Use ``Id::deterministic()`` for stable IDs from payloads. -- Use config objects for policy/output tuning: +Use config objects for coordinated generators and keep them scoped to the +application domain that owns their node/machine ID, epoch, sequence provider and +runtime policy. SnowflakeConfig, SonyflakeConfig, RandflakeConfig and TBSLConfig +may receive a GenerationContext without changing the ordinary synchronous APIs. - - ``SnowflakeConfig`` - - ``SonyflakeConfig`` - - ``TBSLConfig`` +Runwire 2.1.1+ Integration +-------------------------- -- Use value objects for richer domain models: +Runwire support is optional. UID never discovers a runtime globally and never +starts, stops, drives or closes a Runwire runtime, request, scope or event loop. +The host passes the exact runtime/request/scope instances that already own the +operation. - - ``UuidValue`` - - ``UlidValue`` - - ``SnowflakeValue`` - - ``SonyflakeValue`` - - ``TbslValue`` +.. code-block:: php -Distributed Sequence Coordination ---------------------------------- + leaseEndExclusive !== null) { + return $this->leaseEndExclusive; + } + if ($this->leaseEnd === PHP_INT_MAX) { + throw new RandflakeException('randflake: inclusive lease end cannot be converted to an exclusive boundary'); + } + + return $this->leaseEnd + 1; + } } diff --git a/src/Configuration/ResolvesCustomEpoch.php b/src/Configuration/ResolvesCustomEpoch.php index 1d457e9..add2a8d 100644 --- a/src/Configuration/ResolvesCustomEpoch.php +++ b/src/Configuration/ResolvesCustomEpoch.php @@ -10,19 +10,17 @@ trait ResolvesCustomEpoch { public function resolveCustomEpochMs(): ?int { - return self::resolveEpochValue($this->customEpoch); + return $this->customEpoch; } - private static function resolveEpochValue(DateTimeInterface|int|null $customEpoch): ?int + private static function normalizeEpoch(DateTimeInterface|int|null $customEpoch): ?int { if ($customEpoch === null) { return null; } - if ($customEpoch instanceof DateTimeInterface) { - return (int) $customEpoch->format('Uv'); - } - - return $customEpoch; + return $customEpoch instanceof DateTimeInterface + ? (int) $customEpoch->format('Uv') + : $customEpoch; } } diff --git a/src/Configuration/SnowflakeConfig.php b/src/Configuration/SnowflakeConfig.php index 9e8f01a..21e5740 100644 --- a/src/Configuration/SnowflakeConfig.php +++ b/src/Configuration/SnowflakeConfig.php @@ -7,12 +7,15 @@ use Closure; use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SnowflakeConfig { use ResolvesCustomEpoch; + public ?int $customEpoch; + private ?Closure $nodeResolver; /** @@ -23,11 +26,13 @@ public function __construct( public int $datacenterId = 0, public int $workerId = 0, ?callable $nodeResolver = null, - public DateTimeInterface|int|null $customEpoch = null, + DateTimeInterface|int|null $customEpoch = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, + public ?GenerationContext $runtime = null, ) { $this->nodeResolver = $nodeResolver ? $nodeResolver(...) : null; + $this->customEpoch = self::normalizeEpoch($customEpoch); } /** diff --git a/src/Configuration/SonyflakeConfig.php b/src/Configuration/SonyflakeConfig.php index 03aadab..f494ffe 100644 --- a/src/Configuration/SonyflakeConfig.php +++ b/src/Configuration/SonyflakeConfig.php @@ -6,6 +6,8 @@ use DateTimeInterface; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Enums\SonyflakeFormat; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class SonyflakeConfig @@ -13,16 +15,21 @@ use ResolvesCustomEpoch; use ResolvesMachineId; + public ?int $customEpoch; + /** * @param callable():mixed|null $machineIdResolver */ public function __construct( public int $machineId = 0, ?callable $machineIdResolver = null, - public DateTimeInterface|int|null $customEpoch = null, + DateTimeInterface|int|null $customEpoch = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, + public ?GenerationContext $runtime = null, + public SonyflakeFormat $format = SonyflakeFormat::UID, ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; + $this->customEpoch = self::normalizeEpoch($customEpoch); } } diff --git a/src/Configuration/TBSLConfig.php b/src/Configuration/TBSLConfig.php index c3200c0..3458c95 100644 --- a/src/Configuration/TBSLConfig.php +++ b/src/Configuration/TBSLConfig.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Configuration; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\SequenceProviderInterface; final readonly class TBSLConfig @@ -20,6 +21,7 @@ public function __construct( ?callable $machineIdResolver = null, public ?SequenceProviderInterface $sequenceProvider = null, public ClockBackwardPolicy $clockBackwardPolicy = ClockBackwardPolicy::WAIT, + public ?GenerationContext $runtime = null, ) { $this->machineIdResolver = $machineIdResolver ? $machineIdResolver(...) : null; } diff --git a/src/DeterministicId.php b/src/DeterministicId.php index 7ad9e6c..2f489bd 100644 --- a/src/DeterministicId.php +++ b/src/DeterministicId.php @@ -8,11 +8,11 @@ final class DeterministicId { - private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + private const string ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - private const DOMAIN = "infocyph.uid.deterministic.v5\0"; + private const string DOMAIN = "infocyph.uid.deterministic.v5\0"; - private const MAX_LENGTH = 43; + private const int MAX_LENGTH = 43; /** * Generates a deterministic opaque ID from payload. diff --git a/src/Enums/RandflakeFormat.php b/src/Enums/RandflakeFormat.php new file mode 100644 index 0000000..cafeae5 --- /dev/null +++ b/src/Enums/RandflakeFormat.php @@ -0,0 +1,12 @@ +resolveCustomEpochMs()); + $format = SonyflakeFormat::UID; + if ($config !== null) { + $format = $config->format; + } + + return new SonyflakeValue( + self::sonyflake($config), + $config?->resolveCustomEpochMs(), + $format, + ); } public static function tbsl(?TBSLConfig $config = null): string diff --git a/src/IdComparator.php b/src/IdComparator.php index 0cabd13..4032f96 100644 --- a/src/IdComparator.php +++ b/src/IdComparator.php @@ -17,7 +17,12 @@ public static function compare(IdValueInterface|string $left, IdValueInterface|s $leftString = $left instanceof IdValueInterface ? $left->toString() : $left; $rightString = $right instanceof IdValueInterface ? $right->toString() : $right; - if (ctype_digit($leftString) && ctype_digit($rightString)) { + $leftNumeric = ctype_digit($leftString); + $rightNumeric = ctype_digit($rightString); + if ($leftNumeric !== $rightNumeric) { + return $leftNumeric ? -1 : 1; + } + if ($leftNumeric) { return UnsignedDecimal::compare($leftString, $rightString); } diff --git a/src/KSUID.php b/src/KSUID.php index 613daf2..a3a8bb9 100644 --- a/src/KSUID.php +++ b/src/KSUID.php @@ -13,11 +13,11 @@ final class KSUID { - private const EPOCH = 1_400_000_000; + private const int EPOCH = 1_400_000_000; - private const MAX_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V'; + private const string MAX_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V'; - private const MAX_TIMESTAMP_OFFSET = 0xffffffff; + private const int MAX_TIMESTAMP_OFFSET = 0xffffffff; /** * @throws Exception diff --git a/src/NanoID.php b/src/NanoID.php index 75df183..82be887 100644 --- a/src/NanoID.php +++ b/src/NanoID.php @@ -9,7 +9,7 @@ final class NanoID { - private const MAX_LENGTH = 1024; + private const int MAX_LENGTH = 1024; /** * Generates a NanoID string with the requested size. @@ -43,7 +43,7 @@ public static function isValid(string $id, ?int $length = null): bool return false; } - return preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1; + return preg_match('/^[A-Za-z0-9_-]+$/D', $id) === 1; } /** diff --git a/src/OpaqueId.php b/src/OpaqueId.php index 20a62a2..f5262c2 100644 --- a/src/OpaqueId.php +++ b/src/OpaqueId.php @@ -38,7 +38,11 @@ public static function toInt(string $token, string $salt = ''): int $value = $unpacked[1] ?? null; is_int($value) || throw new Exception('Unable to decode opaque token'); $saltMask = crc32($salt); + $decoded = $value ^ $saltMask; + if ($decoded < 0) { + throw new \InvalidArgumentException('Decoded opaque token is outside the supported non-negative domain'); + } - return $value ^ $saltMask; + return $decoded; } } diff --git a/src/Randflake.php b/src/Randflake.php index 081603b..c3587bf 100644 --- a/src/Randflake.php +++ b/src/Randflake.php @@ -7,77 +7,133 @@ use DateTimeImmutable; use Exception; use Infocyph\UID\Configuration\RandflakeConfig; +use Infocyph\UID\Enums\RandflakeFormat; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\RandflakeException; use Infocyph\UID\Exceptions\SequenceTimestampException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\DecimalBytes; use Infocyph\UID\Support\GetSequence; use Infocyph\UID\Support\NumericConversion; +use Infocyph\UID\Support\Sparx64; use Infocyph\UID\Support\UnsignedDecimal; final class Randflake { use GetSequence; - public const EPOCH_OFFSET = 1_730_000_000; + public const int EPOCH_OFFSET = 1_730_000_000; - public const MAX_NODE = (1 << self::NODE_BITS) - 1; + public const int MAX_NODE = (1 << self::NODE_BITS) - 1; - public const MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1; + public const int MAX_SEQUENCE = (1 << self::SEQUENCE_BITS) - 1; - public const MAX_TIMESTAMP = self::EPOCH_OFFSET + self::MAX_TIMESTAMP_PART; + public const int MAX_TIMESTAMP = self::EPOCH_OFFSET + self::MAX_TIMESTAMP_PART; - public const MAX_TIMESTAMP_PART = (1 << self::TIMESTAMP_BITS) - 1; + public const int MAX_TIMESTAMP_PART = (1 << self::TIMESTAMP_BITS) - 1; - public const NODE_BITS = 17; + public const int NODE_BITS = 17; - public const SEQUENCE_BITS = 17; + public const int SEQUENCE_BITS = 17; - public const TIMESTAMP_BITS = 30; + public const int TIMESTAMP_BITS = 30; - /** @var \WeakMap>|null */ + private const int MAX_PROVIDER_DOMAINS = 1024; + + /** @var \WeakMap>|null */ private static ?\WeakMap $lastTimestampByProvider = null; /** @var array> */ private static array $roundKeyCache = []; + /** @var array */ + private static array $sparxCache = []; + /** * @throws RandflakeException */ - public static function decodeString(string $id): string - { + public static function decodeString( + string $id, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if ($format === RandflakeFormat::UPSTREAM) { + if ( + preg_match('/^(?:0|[1-9a-v][0-9a-v]{0,12})$/D', $id) !== 1 + || (strlen($id) > 1 && $id[0] === '0') + ) { + throw new RandflakeException('randflake: invalid id'); + } + + try { + $bytes = BaseEncoder::decodeToBytes($id, 32, 8); + $decoded = DecimalBytes::fromLittleEndianSigned64(strrev($bytes)); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + + if (self::encodeString($decoded, $format) !== $id) { + throw new RandflakeException('randflake: invalid id'); + } + + return $decoded; + } + return NumericConversion::decimalFromBase( $id, 32, 8, - static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException( - $message === '' ? 'randflake: invalid id' : 'randflake: invalid id', - 0, - $exception, - ), + static function (string $message, \InvalidArgumentException $exception): RandflakeException { + unset($message); + + return new RandflakeException('randflake: invalid id', 0, $exception); + }, ); } /** * @throws RandflakeException */ - public static function encodeString(string $id): string - { - if (!self::isValid($id)) { + public static function encodeString( + string $id, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if (!self::isValid($id, $format)) { throw new RandflakeException('randflake: invalid id'); } - return BaseEncoder::encodeBytes(self::toBytes($id), 32); + $bytes = self::toBytes($id, $format); + + return BaseEncoder::encodeBytes( + $format === RandflakeFormat::UPSTREAM ? strrev($bytes) : $bytes, + 32, + ); } /** * @throws RandflakeException */ - public static function fromBase(string $encoded, int $base): string - { + public static function fromBase( + string $encoded, + int $base, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if ($format === RandflakeFormat::UPSTREAM) { + if ($base === 32) { + return self::decodeString($encoded, $format); + } + + try { + return DecimalBytes::fromLittleEndianSigned64( + strrev(BaseEncoder::decodeToBytes($encoded, $base, 8)), + ); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + } + return NumericConversion::decimalFromBase( $encoded, $base, @@ -89,8 +145,18 @@ public static function fromBase(string $encoded, int $base): string /** * @throws RandflakeException */ - public static function fromBytes(string $bytes): string - { + public static function fromBytes( + string $bytes, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if ($format === RandflakeFormat::UPSTREAM) { + try { + return DecimalBytes::fromLittleEndianSigned64($bytes); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + } + return NumericConversion::decimalFromBytes( $bytes, 8, @@ -102,22 +168,35 @@ public static function fromBytes(string $bytes): string /** * @throws RandflakeException|FileLockException */ - public static function generate(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string - { + public static function generate( + int $nodeId, + int $leaseStart, + int $leaseEnd, + #[\SensitiveParameter] + string $secret, + ): string { return self::generateInternal( $nodeId, $leaseStart, $leaseEnd, $secret, null, + null, + RandflakeFormat::UID, + null, ); } /** * @throws RandflakeException|FileLockException */ - public static function generateString(int $nodeId, int $leaseStart, int $leaseEnd, string $secret): string - { + public static function generateString( + int $nodeId, + int $leaseStart, + int $leaseEnd, + #[\SensitiveParameter] + string $secret, + ): string { return self::encodeString(self::generate($nodeId, $leaseStart, $leaseEnd, $secret)); } @@ -132,6 +211,11 @@ public static function generateWithConfig(RandflakeConfig $config): string $config->leaseEnd, $config->secret, $config->sequenceProvider, + $config->runtime, + $config->format, + $config->format === RandflakeFormat::UPSTREAM + ? $config->resolveLeaseEndExclusive() + : null, ); } @@ -139,15 +223,20 @@ public static function generateWithConfig(RandflakeConfig $config): string * @return array{timestamp: int, node_id: int, sequence: int} * @throws RandflakeException */ - public static function inspect(string $id, string $secret): array - { - if (!self::isValid($id)) { + public static function inspect( + string $id, + #[\SensitiveParameter] + string $secret, + RandflakeFormat $format = RandflakeFormat::UID, + ): array { + if (!self::isValid($id, $format)) { throw new RandflakeException('randflake: invalid id'); } [$timestamp, $nodeId, $sequence] = self::inspectBytes( - self::toBytes($id), + self::toBytes($id, $format), self::validateSecret($secret), + $format, ); return [ @@ -161,13 +250,23 @@ public static function inspect(string $id, string $secret): array * @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 inspectString( + string $id, + #[\SensitiveParameter] + string $secret, + RandflakeFormat $format = RandflakeFormat::UID, + ): array { + return self::inspect(self::decodeString($id, $format), $secret, $format); } - public static function isValid(string $id): bool - { + public static function isValid( + string $id, + RandflakeFormat $format = RandflakeFormat::UID, + ): bool { + if ($format === RandflakeFormat::UPSTREAM) { + return DecimalBytes::isSigned64($id); + } + return $id !== '' && ctype_digit($id) && UnsignedDecimal::compare($id, '18446744073709551615') <= 0; @@ -177,15 +276,20 @@ public static function isValid(string $id): bool * @return array{time: DateTimeImmutable, node_id: int, sequence: int} * @throws Exception */ - public static function parse(string $id, string $secret): array - { - if (!self::isValid($id)) { + public static function parse( + string $id, + #[\SensitiveParameter] + string $secret, + RandflakeFormat $format = RandflakeFormat::UID, + ): array { + if (!self::isValid($id, $format)) { throw new RandflakeException('randflake: invalid id'); } [$timestamp, $nodeId, $sequence] = self::inspectBytes( - self::toBytes($id), + self::toBytes($id, $format), self::validateSecret($secret), + $format, ); return [ @@ -199,28 +303,54 @@ public static function parse(string $id, string $secret): array * @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); + public static function parseString( + string $id, + #[\SensitiveParameter] + string $secret, + RandflakeFormat $format = RandflakeFormat::UID, + ): array { + return self::parse(self::decodeString($id, $format), $secret, $format); } /** * @throws RandflakeException */ - public static function toBase(string $id, int $base): string - { - return BaseEncoder::encodeBytes(self::toBytes($id), $base); + public static function toBase( + string $id, + int $base, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if ($format === RandflakeFormat::UPSTREAM && $base === 32) { + return self::encodeString($id, $format); + } + + $bytes = self::toBytes($id, $format); + + return BaseEncoder::encodeBytes( + $format === RandflakeFormat::UPSTREAM ? strrev($bytes) : $bytes, + $base, + ); } /** * @throws RandflakeException */ - public static function toBytes(string $id): string - { + public static function toBytes( + string $id, + RandflakeFormat $format = RandflakeFormat::UID, + ): string { + if ($format === RandflakeFormat::UPSTREAM) { + try { + return DecimalBytes::toLittleEndianSigned64($id); + } catch (\InvalidArgumentException $exception) { + throw new RandflakeException('randflake: invalid id', 0, $exception); + } + } + return NumericConversion::bytesFromDecimal( $id, 8, - self::isValid(...), + static fn(string $value): bool => self::isValid($value, RandflakeFormat::UID), 'randflake: invalid id', 'randflake: invalid id', static fn(string $message, \InvalidArgumentException $exception): RandflakeException => new RandflakeException($message, 0, $exception), @@ -228,40 +358,25 @@ public static function toBytes(string $id): string } /** - * @throws RandflakeException|FileLockException + * @return array{0:int,1:int} */ - private static function generateInternal( + private static function allocateSequence( + SequenceProviderInterface $provider, int $nodeId, + int $now, int $leaseStart, int $leaseEnd, - string $secret, - ?SequenceProviderInterface $sequenceProvider, - ): string { - self::validateNode($nodeId); - self::validateLeaseWindow($leaseStart, $leaseEnd); - $secret = self::validateSecret($secret); - - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); - 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'); - } - - if ($now > self::MAX_TIMESTAMP) { - throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); - } - - $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'); - } + RandflakeFormat $format, + ?int $leaseEndExclusive, + ?GenerationContext $runtime, + ): array { + $type = $format === RandflakeFormat::UID ? 'randflake' : 'randflake_upstream'; try { - $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)]; } catch (SequenceTimestampException $exception) { - $now = time(); + $now = self::nowSeconds($runtime); + self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive); if ($now < $exception->lastTimestamp) { throw new RandflakeException( 'randflake: timestamp consistency violation, the current time is less than the persisted time', @@ -270,34 +385,104 @@ private static function generateInternal( ); } - $sequenceValue = self::sequence($now, $nodeId, 'randflake', $resolvedSequenceProvider); + return [$now, self::sequence($now, $nodeId, $type, $provider, $runtime)]; } - if ($sequenceValue < 1) { - throw new RandflakeException('randflake: sequence provider must return a positive integer'); + } + + private static function assertGenerationTime( + int $now, + int $leaseStart, + int $leaseEnd, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ): void { + if (!self::leaseContains($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive)) { + throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); } - $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)", + if ($now < self::EPOCH_OFFSET || $now > self::MAX_TIMESTAMP) { + throw new RandflakeException('randflake: the randflake id is dead after 34 years of lifetime'); + } + } + + private static function encodeGeneratedPayload( + int $timestamp, + int $nodeId, + int $sequence, + #[\SensitiveParameter] + string $secret, + RandflakeFormat $format, + ): string { + $plain = self::packPayload($timestamp, $nodeId, $sequence); + if ($format === RandflakeFormat::UPSTREAM) { + return DecimalBytes::fromLittleEndianSigned64( + self::sparx($secret)->encrypt(strrev($plain)), ); } - $providerState[$nodeId] = $now; + return DecimalBytes::fromBytes(self::permute($plain, $secret, false)); + } - $plain = self::packPayload($now, $nodeId, $sequence); - $cipher = self::permute($plain, $secret, false); + /** + * @throws RandflakeException|FileLockException + */ + private static function generateInternal( + int $nodeId, + int $leaseStart, + int $leaseEnd, + #[\SensitiveParameter] + string $secret, + ?SequenceProviderInterface $sequenceProvider, + ?GenerationContext $runtime, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ): string { + self::validateNode($nodeId); + self::validateLeaseWindow($leaseStart, $leaseEnd, $format, $leaseEndExclusive); + $secret = self::validateSecret($secret); + + $provider = self::resolveSequenceProvider($sequenceProvider); + $domainKey = $format->value . ':' . $nodeId; + $state = self::providerState($provider, $domainKey); + $now = self::nowSeconds($runtime); + self::assertGenerationTime($now, $leaseStart, $leaseEnd, $format, $leaseEndExclusive); + + $last = $state[$domainKey] ?? null; + if ($last !== null && $now < $last['timestamp']) { + throw new RandflakeException( + 'randflake: timestamp consistency violation, the current time is less than the last time', + ); + } + + [$now, $allocation] = self::allocateSequence( + $provider, + $nodeId, + $now, + $leaseStart, + $leaseEnd, + $format, + $leaseEndExclusive, + $runtime, + ); + // A callback provider may suspend while another request advances this domain. + $sequence = self::normalizeAllocation($allocation, $state[$domainKey] ?? null, $now); + $state[$domainKey] = ['timestamp' => $now, 'sequence' => $sequence]; - return DecimalBytes::fromBytes($cipher); + return self::encodeGeneratedPayload($now, $nodeId, $sequence, $secret, $format); } /** * @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); + private static function inspectBytes( + string $cipherBytes, + string $secret, + RandflakeFormat $format, + ): array { + $plain = $format === RandflakeFormat::UPSTREAM + ? strrev(self::sparx($secret)->decrypt($cipherBytes)) + : self::permute($cipherBytes, $secret, true); [$timestamp, $nodeId, $sequence] = self::unpackPayload($plain); if ( @@ -314,6 +499,50 @@ private static function inspectBytes(string $cipherBytes, string $secret): array return [$timestamp, $nodeId, $sequence]; } + private static function leaseContains( + int $timestamp, + int $leaseStart, + int $leaseEnd, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ): bool { + if ($format === RandflakeFormat::UPSTREAM) { + return $timestamp >= $leaseStart && $timestamp < (int) $leaseEndExclusive; + } + + return $timestamp >= $leaseStart && $timestamp <= $leaseEnd; + } + + /** + * @param array{timestamp:int,sequence:int}|null $last + */ + private static function normalizeAllocation(int $allocation, ?array $last, int $now): int + { + if ($allocation < 1) { + throw new RandflakeException('randflake: sequence provider must return a positive integer'); + } + + $sequence = $allocation - 1; + if ($last !== null && $now < $last['timestamp']) { + throw new RandflakeException('randflake: sequence allocation timestamp regressed for the active provider domain'); + } + if ($last !== null && $last['timestamp'] === $now && $sequence <= $last['sequence']) { + throw new RandflakeException('randflake: sequence allocation regressed for the active provider domain'); + } + if ($sequence > self::MAX_SEQUENCE) { + throw new RandflakeException( + "randflake: resource exhausted (generator can't handle current throughput, try using multiple randflake instances)", + ); + } + + return $sequence; + } + + private static function nowSeconds(?GenerationContext $runtime): int + { + return $runtime?->nowSeconds() ?? time(); + } + private static function packPayload(int $timestamp, int $nodeId, int $sequence): string { $timestampPart = $timestamp - self::EPOCH_OFFSET; @@ -353,9 +582,33 @@ private static function permute(string $block, string $secret, bool $decrypt): s return pack('N2', $left, $right); } + /** + * @return \ArrayObject + */ + private static function providerState( + SequenceProviderInterface $provider, + string $domainKey, + ): \ArrayObject { + self::$lastTimestampByProvider ??= new \WeakMap(); + + /** @var \ArrayObject|null $state */ + $state = self::$lastTimestampByProvider[$provider] ?? null; + if ($state === null) { + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastTimestampByProvider[$provider] = $state; + } + + if (!isset($state[$domainKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) { + throw new RandflakeException('randflake: provider domain limit exceeded'); + } + + return $state; + } + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { - return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); + return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(reservationSize: 64); } private static function roundFunction(int $value, int $key): int @@ -395,6 +648,22 @@ private static function roundKeys(string $secret): array return self::$roundKeyCache[$fingerprint] = $keys; } + private static function sparx( + #[\SensitiveParameter] + string $secret, + ): Sparx64 { + $fingerprint = hash('sha256', $secret); + if (isset(self::$sparxCache[$fingerprint])) { + return self::$sparxCache[$fingerprint]; + } + + if (count(self::$sparxCache) === 16) { + array_shift(self::$sparxCache); + } + + return self::$sparxCache[$fingerprint] = new Sparx64($secret); + } + /** * @param array|false $parts * @throws RandflakeException @@ -432,8 +701,25 @@ private static function unpackPayload(string $payload): array /** * @throws RandflakeException */ - private static function validateLeaseWindow(int $leaseStart, int $leaseEnd): void - { + private static function validateLeaseWindow( + int $leaseStart, + int $leaseEnd, + RandflakeFormat $format, + ?int $leaseEndExclusive, + ): void { + if ($format === RandflakeFormat::UPSTREAM) { + if ( + $leaseEndExclusive === null + || $leaseStart < self::EPOCH_OFFSET + || $leaseEndExclusive <= $leaseStart + || $leaseEndExclusive > self::MAX_TIMESTAMP + 1 + ) { + throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); + } + + return; + } + if ($leaseStart > $leaseEnd || $leaseEnd > self::MAX_TIMESTAMP) { throw new RandflakeException('randflake: invalid lease, lease expired or not started yet'); } @@ -452,8 +738,10 @@ private static function validateNode(int $nodeId): void /** * @throws RandflakeException */ - private static function validateSecret(string $secret): string - { + private static function validateSecret( + #[\SensitiveParameter] + string $secret, + ): string { if (strlen($secret) !== 16) { throw new RandflakeException('randflake: invalid secret, secret must be 16 bytes long'); } diff --git a/src/RandomId.php b/src/RandomId.php index 799cf1d..afe0dc9 100644 --- a/src/RandomId.php +++ b/src/RandomId.php @@ -8,7 +8,7 @@ final class RandomId { - public const DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + public const string DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; public static function generate(int $length = 21, string $alphabet = self::DEFAULT_ALPHABET): string { diff --git a/src/Runtime/GenerationContext.php b/src/Runtime/GenerationContext.php new file mode 100644 index 0000000..4b23912 --- /dev/null +++ b/src/Runtime/GenerationContext.php @@ -0,0 +1,78 @@ +runwire?->assertActive(); + } + + public function nowMicroseconds(): int + { + $this->assertActive(); + + if ($this->clock === null) { + return (int) floor(microtime(true) * 1_000_000); + } + + $now = $this->clock->now(); + $seconds = $now->getTimestamp(); + if ($seconds < 0 || $seconds > intdiv(PHP_INT_MAX - 999_999, 1_000_000)) { + throw new InvalidArgumentException('Generation clock must fit in non-negative integer microseconds'); + } + + return ($seconds * 1_000_000) + (int) $now->format('u'); + } + + public function nowMilliseconds(): int + { + return intdiv($this->nowMicroseconds(), 1_000); + } + + public function nowSeconds(): int + { + return intdiv($this->nowMicroseconds(), 1_000_000); + } + + public function sleepMicroseconds(int $microseconds): void + { + $this->assertActive(); + if ($this->runwire !== null) { + $this->runwire->sleep($microseconds / 1_000_000); + $this->assertActive(); + + return; + } + + usleep($microseconds); + } + + public function waitDeadlineNanoseconds(): int + { + $now = hrtime(true); + $timeout = min($this->waitTimeoutMicros, intdiv(PHP_INT_MAX - $now, 1_000)); + $deadline = $now + ($timeout * 1_000); + $runwireDeadline = $this->runwire?->deadlineNanoseconds(); + + return $runwireDeadline === null ? $deadline : min($deadline, $runwireDeadline); + } +} diff --git a/src/Runtime/RunwireBinding.php b/src/Runtime/RunwireBinding.php new file mode 100644 index 0000000..810eb9d --- /dev/null +++ b/src/Runtime/RunwireBinding.php @@ -0,0 +1,83 @@ +assertActive(); + } + + public function assertActive(): void + { + $pid = getmypid(); + if (!is_int($pid) || $pid !== $this->runtime->pid) { + throw new LogicException('Runwire binding belongs to a different process.'); + } + + if ($this->request !== null) { + if ($this->request->runtime() !== $this->runtime) { + throw new LogicException('Runwire request belongs to a different runtime context.'); + } + if ($this->request->completed()) { + throw new LogicException('Runwire request has already completed.'); + } + + $this->request->cancellation->throwIfCancelled(); + } + + if ($this->scope !== null) { + if (!$this->runtime->supports(RuntimeCapability::RUNWIRE_COROUTINES)) { + throw new LogicException('Runwire coroutine scope requires coroutine capability.'); + } + + self::$scopeProbe ??= new TaskLocal(); + $this->scope->hasLocal(self::$scopeProbe); + $this->scope->cancellation()->throwIfCancelled(); + } + } + + public function deadlineNanoseconds(): ?int + { + $this->assertActive(); + $requestDeadline = $this->request?->deadline()->monotonicNanoseconds; + $scopeDeadline = $this->scope?->cancellation()->deadline()->monotonicNanoseconds; + + if ($requestDeadline === null) { + return $scopeDeadline; + } + if ($scopeDeadline === null) { + return $requestDeadline; + } + + return min($requestDeadline, $scopeDeadline); + } + + public function sleep(float $seconds): void + { + $this->assertActive(); + + if ($this->scope !== null) { + $this->scope->sleep($seconds); + } else { + usleep((int) ceil($seconds * 1_000_000)); + } + + $this->assertActive(); + } +} diff --git a/src/Sequence/FilesystemSequenceProvider.php b/src/Sequence/FilesystemSequenceProvider.php index 6009a9d..741c711 100644 --- a/src/Sequence/FilesystemSequenceProvider.php +++ b/src/Sequence/FilesystemSequenceProvider.php @@ -6,14 +6,17 @@ use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Support\FileLock; use InvalidArgumentException; final class FilesystemSequenceProvider implements SequenceProviderInterface { - private const MAX_PATH_CACHE = 1024; + private const int MAX_PATH_CACHE = 1024; - private const MAX_SEQUENCE_STATE_BYTES = 64; + private const int MAX_RESERVATIONS = 1024; + + private const int MAX_SEQUENCE_STATE_BYTES = 64; private readonly string $baseDirectory; @@ -30,6 +33,7 @@ public function __construct( private readonly string $namespace = '', private readonly ?int $lockTimeoutMicros = null, private readonly int $reservationSize = 1, + private readonly ?GenerationContext $runtime = null, ) { $this->baseDirectory = $baseDirectory ?: sys_get_temp_dir(); @@ -46,21 +50,18 @@ public function __construct( } } - public function next(string $type, int $machineId, int $timestamp): int + public function next(string $type, int $machineId, int $timestamp, ?GenerationContext $runtime = null): int { + $this->runtime?->assertActive(); + $runtime ??= $this->runtime; + $runtime?->assertActive(); $fileLocation = $this->sequenceFileLocation($type, $machineId); - $this->resetAfterFork(); - $reservation = $this->reservations[$fileLocation] ?? null; - - if ( - $reservation !== null - && $reservation['timestamp'] === $timestamp - && $reservation['next'] <= $reservation['end'] - ) { - $allocation = $reservation['next']; - $this->reservations[$fileLocation]['next'] = $allocation + 1; - - return $allocation; + if ($this->reservationSize > 1) { + $this->resetAfterFork(); + $reserved = $this->takeReservedAllocation($fileLocation, $timestamp); + if ($reserved !== null) { + return $reserved; + } } $handle = FileLock::acquire( @@ -68,6 +69,7 @@ public function next(string $type, int $machineId, int $timestamp): int $this->lockTimeoutMicros, 'Failed to open sequence file: ' . $fileLocation, 'Unable to acquire sequence lock: ' . $fileLocation, + $runtime, ); try { @@ -75,20 +77,22 @@ public function next(string $type, int $machineId, int $timestamp): int if ($lastTimestamp > $timestamp) { throw new SequenceTimestampException($lastTimestamp, $timestamp); } + if ($lastTimestamp === $timestamp && $lastAllocation === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted'); + } $allocation = $lastTimestamp === $timestamp ? $lastAllocation + 1 : 1; - if ($allocation > PHP_INT_MAX - $this->reservationSize + 1) { + $reservationOffset = $this->reservationSize - 1; + if ($allocation > PHP_INT_MAX - $reservationOffset) { throw new FileLockException('Sequence value exhausted'); } - $reservedEnd = $allocation + $this->reservationSize - 1; - $state = $timestamp . ',' . $reservedEnd; - $this->writeState($handle, $state, $oldLength); - $this->reservations[$fileLocation] = [ - 'timestamp' => $timestamp, - 'next' => $allocation + 1, - 'end' => $reservedEnd, - ]; + $reservedEnd = $allocation + $reservationOffset; + $runtime?->assertActive(); + $this->writeState($handle, $timestamp . ',' . $reservedEnd, $oldLength); + if ($this->reservationSize > 1) { + $this->storeReservation($fileLocation, $timestamp, $allocation, $reservedEnd); + } return $allocation; } finally { @@ -97,13 +101,6 @@ public function next(string $type, int $machineId, int $timestamp): int } } - private static function isCanonicalInteger(string $value): bool - { - return $value !== '' - && ctype_digit($value) - && ($value === '0' || $value[0] !== '0'); - } - /** * @param resource $handle * @return array{0:int,1:int,2:int} @@ -124,22 +121,15 @@ private function readState($handle): array return [0, 0, 0]; } - $comma = strpos($state, ','); - if ($comma === false || str_contains(substr($state, $comma + 1), ',')) { - throw new FileLockException('Sequence state is malformed'); - } - - $timestamp = substr($state, 0, $comma); - $allocation = substr($state, $comma + 1); - if (!self::isCanonicalInteger($timestamp) || !self::isCanonicalInteger($allocation)) { + if (preg_match('/\A(0|[1-9][0-9]{0,18}),(0|[1-9][0-9]{0,18})\z/', $state, $parts) !== 1) { throw new FileLockException('Sequence state is malformed'); } + $timestamp = $parts[1]; + $allocation = $parts[2]; if ( - strlen($timestamp) > 19 - || strlen($allocation) > 19 - || (strlen($timestamp) === 19 && $timestamp > (string) PHP_INT_MAX) - || (strlen($allocation) === 19 && $allocation > (string) PHP_INT_MAX) + (strlen($timestamp) === 19 && strcmp($timestamp, (string) PHP_INT_MAX) > 0) + || (strlen($allocation) === 19 && strcmp($allocation, (string) PHP_INT_MAX) > 0) ) { throw new FileLockException('Sequence state is malformed'); } @@ -177,6 +167,44 @@ private function sequenceFileLocation(string $type, int $machineId): string return $this->pathCache[$cacheKey] = $this->baseDirectory . DIRECTORY_SEPARATOR . $name; } + private function storeReservation( + string $fileLocation, + int $timestamp, + int $allocation, + int $reservedEnd, + ): void { + if (!isset($this->reservations[$fileLocation]) && count($this->reservations) >= self::MAX_RESERVATIONS) { + throw new FileLockException('Sequence reservation domain limit exceeded'); + } + + $this->reservations[$fileLocation] = [ + 'timestamp' => $timestamp, + 'next' => $allocation + 1, + 'end' => $reservedEnd, + ]; + } + + private function takeReservedAllocation(string $fileLocation, int $timestamp): ?int + { + $reservation = $this->reservations[$fileLocation] ?? null; + if ( + $reservation === null + || $reservation['timestamp'] !== $timestamp + || $reservation['next'] > $reservation['end'] + ) { + return null; + } + + $allocation = $reservation['next']; + if ($allocation === $reservation['end']) { + unset($this->reservations[$fileLocation]); + } else { + $this->reservations[$fileLocation]['next'] = $allocation + 1; + } + + return $allocation; + } + /** * @param resource $handle */ diff --git a/src/Sequence/InMemorySequenceProvider.php b/src/Sequence/InMemorySequenceProvider.php index 5f0265e..26b6009 100644 --- a/src/Sequence/InMemorySequenceProvider.php +++ b/src/Sequence/InMemorySequenceProvider.php @@ -9,6 +9,8 @@ final class InMemorySequenceProvider implements SequenceProviderInterface { + private const int MAX_DOMAINS = 1024; + /** * @var array */ @@ -17,6 +19,10 @@ final class InMemorySequenceProvider implements SequenceProviderInterface public function next(string $type, int $machineId, int $timestamp): int { $key = $this->key($type, $machineId); + if (!isset($this->state[$key]) && count($this->state) >= self::MAX_DOMAINS) { + throw new FileLockException('In-memory sequence domain limit exceeded'); + } + $last = $this->state[$key] ?? null; $sequence = 1; diff --git a/src/Sequence/PsrSimpleCacheSequenceProvider.php b/src/Sequence/PsrSimpleCacheSequenceProvider.php index 037041f..918e6ec 100644 --- a/src/Sequence/PsrSimpleCacheSequenceProvider.php +++ b/src/Sequence/PsrSimpleCacheSequenceProvider.php @@ -7,28 +7,42 @@ use Closure; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Support\FileLock; use InvalidArgumentException; use Psr\SimpleCache\CacheInterface; use Throwable; -final readonly class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface +final class PsrSimpleCacheSequenceProvider implements SequenceProviderInterface { - private ?Closure $synchronizer; + private const int MAX_OBSERVED_DOMAINS = 1024; + + private readonly ?Closure $synchronizer; + + /** @var array */ + private array $observedState = []; /** * @param callable(string, callable():int):mixed|null $synchronizer */ public function __construct( - private CacheInterface $cache, - private string $prefix = 'uid.seq.', - private int $waitTime = 1_000, - private int $maxAttempts = 1_000, + private readonly CacheInterface $cache, + private readonly string $prefix = 'uid.seq.', + private readonly int $waitTime = 1_000, + private readonly int $maxAttempts = 1_000, ?callable $synchronizer = null, + private readonly ?GenerationContext $runtime = 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'); } + if ( + $waitTime < 1 + || $maxAttempts < 1 + || $waitTime > intdiv(PHP_INT_MAX, $maxAttempts) + ) { + throw new InvalidArgumentException('Cache sequence wait policy must contain positive bounded values'); + } $this->synchronizer = $synchronizer ? $synchronizer(...) : null; } @@ -36,56 +50,77 @@ public function __construct( /** * @throws FileLockException */ - public function next(string $type, int $machineId, int $timestamp): int + public function next(string $type, int $machineId, int $timestamp, ?GenerationContext $runtime = null): int { + $this->runtime?->assertActive(); + $runtime ??= $this->runtime; + $runtime?->assertActive(); $key = $this->key($type, $machineId); + if (!isset($this->observedState[$key]) && count($this->observedState) >= self::MAX_OBSERVED_DOMAINS) { + throw new FileLockException('Observed PSR-16 sequence domain limit exceeded'); + } if ($this->synchronizer !== null) { - try { - $sequence = ($this->synchronizer)( - $key, - fn(): int => $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, - 0, - $exception, - ); - } - - if (!is_int($sequence) || $sequence < 1) { - throw new FileLockException('Sequence synchronizer must return a positive integer'); - } - - return $sequence; - } - - $lock = $this->acquireLock($key); + return $this->nextSynchronized($this->synchronizer, $key, $timestamp, $runtime); + } + + $lock = $this->acquireLock($key, $runtime); 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, - 0, - $exception, - ); + return $this->nextSafely($key, $timestamp, $runtime); } finally { flock($lock, LOCK_UN); fclose($lock); } } + /** + * @param array{timestamp:int,sequence:int}|null $state + * @param array{timestamp:int,sequence:int}|null $observed + */ + private static function assertNotRegressed(?array $state, ?array $observed, string $key): void + { + if ($state === null || $observed === null) { + return; + } + if ($state['timestamp'] < $observed['timestamp']) { + throw new FileLockException('Cached sequence state regressed for key: ' . $key); + } + if ($state['timestamp'] === $observed['timestamp'] && $state['sequence'] < $observed['sequence']) { + throw new FileLockException('Cached sequence state regressed for key: ' . $key); + } + } + + /** + * @param array{timestamp:int,sequence:int}|null $state + */ + private static function nextSequence(?array $state, int $timestamp, string $key): int + { + if ($state === null) { + return 1; + } + if ($state['timestamp'] > $timestamp) { + throw new SequenceTimestampException( + $state['timestamp'], + $timestamp, + 'Sequence timestamp moved backwards for key: ' . $key, + ); + } + if ($state['timestamp'] !== $timestamp) { + return 1; + } + if ($state['sequence'] === PHP_INT_MAX) { + throw new FileLockException('Sequence value exhausted for key: ' . $key); + } + + return $state['sequence'] + 1; + } + /** * @return resource * @throws FileLockException */ - private function acquireLock(string $key) + private function acquireLock(string $key, ?GenerationContext $runtime) { $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-cache-lock-' . hash('sha256', $key) . '.lck'; @@ -94,6 +129,7 @@ private function acquireLock(string $key) $this->waitTime * $this->maxAttempts, 'Unable to open sequence cache lock file: ' . $lockFile, 'Unable to acquire sequence cache lock for key: ' . $key, + $runtime, ); } @@ -111,43 +147,95 @@ private function key(string $type, int $machineId): string return $key; } - private function nextFromCacheState(string $key, int $timestamp): int + private function nextFromCacheState(string $key, int $timestamp, ?GenerationContext $runtime): int { - $state = $this->cache->get($key); - $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; - } - } - - if (!$this->cache->set($key, ['timestamp' => $timestamp, 'sequence' => $sequence])) { + $runtime?->assertActive(); + $state = $this->normalizeState($this->cache->get($key), $key); + $observed = $this->observedState[$key] ?? null; + if ($state === null && $observed !== null) { + throw new FileLockException('Cached sequence state was lost for key: ' . $key); + } + + self::assertNotRegressed($state, $observed, $key); + $sequence = self::nextSequence($state, $timestamp, $key); + $nextState = ['timestamp' => $timestamp, 'sequence' => $sequence]; + $runtime?->assertActive(); + if (!$this->cache->set($key, $nextState)) { throw new FileLockException('Failed to persist sequence state for key: ' . $key); } + $this->observedState[$key] = $nextState; + return $sequence; } + + private function nextSafely(string $key, int $timestamp, ?GenerationContext $runtime): int + { + try { + return $this->nextFromCacheState($key, $timestamp, $runtime); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + $runtime?->assertActive(); + + throw $this->storageFailure($key, $exception); + } + } + + private function nextSynchronized(Closure $synchronizer, string $key, int $timestamp, ?GenerationContext $runtime): int + { + try { + $sequence = $synchronizer( + $key, + fn(): int => $this->nextFromCacheState($key, $timestamp, $runtime), + ); + } catch (FileLockException $exception) { + throw $exception; + } catch (Throwable $exception) { + $runtime?->assertActive(); + + throw $this->storageFailure($key, $exception); + } + + if (!is_int($sequence) || $sequence < 1) { + throw new FileLockException('Sequence synchronizer must return a positive integer'); + } + + return $sequence; + } + + /** + * @return array{timestamp:int,sequence:int}|null + */ + private function normalizeState(mixed $state, string $key): ?array + { + if ($state === null) { + return null; + } + if (!is_array($state)) { + throw new FileLockException('Cached sequence state is malformed for key: ' . $key); + } + + $stateTimestamp = $state['timestamp'] ?? null; + $stateSequence = $state['sequence'] ?? null; + if ( + !is_int($stateTimestamp) + || !is_int($stateSequence) + || $stateTimestamp < 0 + || $stateSequence < 1 + ) { + throw new FileLockException('Cached sequence state is malformed for key: ' . $key); + } + + return ['timestamp' => $stateTimestamp, 'sequence' => $stateSequence]; + } + + private function storageFailure(string $key, Throwable $exception): FileLockException + { + return new FileLockException( + 'Failed to read/write sequence state from PSR cache for key: ' . $key, + 0, + $exception, + ); + } } diff --git a/src/Snowflake.php b/src/Snowflake.php index 1f46468..0a17b0d 100644 --- a/src/Snowflake.php +++ b/src/Snowflake.php @@ -11,6 +11,7 @@ use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SnowflakeException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; @@ -22,15 +23,19 @@ final class Snowflake { use GetSequence; - private const DATACENTER_BITS = 5; + private const int DATACENTER_BITS = 5; - private const DEFAULT_EPOCH = 1_577_836_800_000; + private const int DEFAULT_EPOCH = 1_577_836_800_000; - private const SEQUENCE_BITS = 12; + private const int MAX_PROVIDER_DOMAINS = 1024; - private const TIMESTAMP_BITS = 41; + private const int SEQUENCE_BITS = 12; - private const WORKER_BITS = 5; + private const int TIMESTAMP_BITS = 41; + + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + + private const int WORKER_BITS = 5; /** @var \WeakMap>|null */ private static ?\WeakMap $lastStateByProvider = null; @@ -68,7 +73,7 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string return self::generateInternal( $datacenter, $workerId, - self::getStartTimeStamp(), + self::DEFAULT_EPOCH, ClockBackwardPolicy::WAIT, ); } @@ -81,14 +86,15 @@ public static function generate(int $datacenter = 0, int $workerId = 0): string public static function generateWithConfig(SnowflakeConfig $config): string { [$datacenterId, $workerId] = $config->resolveNode(); - $customEpoch = $config->resolveCustomEpochMs(); + $customEpoch = $config->customEpoch; return self::generateInternal( $datacenterId, $workerId, - $customEpoch ?? self::getStartTimeStamp(), + $customEpoch ?? self::DEFAULT_EPOCH, $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, ); } @@ -113,7 +119,7 @@ public static function parse(string $id): array { return self::parseWithEpoch( id: $id, - startTimestamp: self::getStartTimeStamp(), + startTimestamp: self::DEFAULT_EPOCH, ); } @@ -166,6 +172,15 @@ public static function toBytes(string $id): string return self::encodeNumericBytes($id); } + private static function assertDecodedId(string $id): string + { + if (!self::isValid($id)) { + throw new SnowflakeException('Decoded Snowflake ID exceeds the supported signed domain'); + } + + return $id; + } + /** * @throws SnowflakeException */ @@ -201,22 +216,26 @@ private static function assertTimestampRange(int $currentTime, int $startTimesta private static function decodeNumericBase(string $encoded, int $base): string { - return NumericConversion::decimalFromBase( + $id = NumericConversion::decimalFromBase( $encoded, $base, 8, static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception), ); + + return self::assertDecodedId($id); } private static function decodeNumericBytes(string $bytes): string { - return NumericConversion::decimalFromBytes( + $id = NumericConversion::decimalFromBytes( $bytes, 8, 'Snowflake binary data must be exactly 8 bytes', static fn(string $message, \InvalidArgumentException $exception): SnowflakeException => new SnowflakeException($message, 0, $exception), ); + + return self::assertDecodedId($id); } private static function encodeNumericBytes(string $id): string @@ -240,22 +259,22 @@ private static function generateInternal( int $startTimestamp, ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, + ?GenerationContext $runtime = null, ): string { self::assertNodeIds($datacenter, $workerId); - $currentTime = (int) floor(microtime(true) * 1000); - self::assertTimestampRange($currentTime, $startTimestamp); - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); $sequenceKey = ($datacenter << self::WORKER_BITS) | $workerId; $stateKey = $startTimestamp . ':' . $sequenceKey; - self::$lastStateByProvider ??= new \WeakMap(); - $providerState = self::$lastStateByProvider[$resolvedSequenceProvider] ??= new \ArrayObject(); + $providerState = self::providerState($resolvedSequenceProvider, $stateKey); $maxSequence = -1 ^ (-1 << self::SEQUENCE_BITS); $sequenceType = $startTimestamp === self::DEFAULT_EPOCH ? 'snowflake' : 'snowflake_' . $startTimestamp; + $currentTime = self::nowMilliseconds($runtime); + self::assertTimestampRange($currentTime, $startTimestamp); + while (true) { [$currentTime, $sequence] = self::nextSequenceAtValidTimestamp( $currentTime, @@ -265,6 +284,7 @@ private static function generateInternal( $clockBackwardPolicy, $resolvedSequenceProvider, $sequenceType, + $runtime, ); $lastState = $providerState[$stateKey] ?? null; @@ -279,7 +299,7 @@ private static function generateInternal( $currentTime < $lastState['timestamp'] || ($currentTime === $lastState['timestamp'] && $sequence <= $lastState['sequence']) ) { - $currentTime = self::waitUntil($lastState['timestamp'] + 1); + $currentTime = self::waitUntil($lastState['timestamp'] + 1, $runtime); self::assertTimestampRange($currentTime, $startTimestamp); continue; @@ -303,14 +323,6 @@ private static function generateInternal( | ($sequence)); } - /** - * Retrieves the start timestamp. - */ - private static function getStartTimeStamp(): int - { - return self::DEFAULT_EPOCH; - } - /** * @return array{0:int, 1:int} * @throws FileLockException|SnowflakeException @@ -323,10 +335,11 @@ private static function nextSequenceAtValidTimestamp( ClockBackwardPolicy $clockBackwardPolicy, SequenceProviderInterface $sequenceProvider, string $sequenceType, + ?GenerationContext $runtime, ): array { while (true) { try { - $allocation = self::sequence($currentTime, $sequenceKey, $sequenceType, $sequenceProvider); + $allocation = self::sequence($currentTime, $sequenceKey, $sequenceType, $sequenceProvider, $runtime); } catch (SequenceTimestampException $exception) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new SnowflakeException( @@ -336,7 +349,7 @@ private static function nextSequenceAtValidTimestamp( ); } - $currentTime = self::waitUntil($exception->lastTimestamp); + $currentTime = self::waitUntil($exception->lastTimestamp, $runtime); self::assertTimestampRange($currentTime, $startTimestamp); continue; @@ -350,11 +363,42 @@ private static function nextSequenceAtValidTimestamp( return [$currentTime, $allocation - 1]; } - $currentTime = self::waitUntil($currentTime + 1); + $currentTime = self::waitUntil($currentTime + 1, $runtime); self::assertTimestampRange($currentTime, $startTimestamp); } } + private static function nowMilliseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); + } + + /** + * @return \ArrayObject + */ + private static function providerState( + SequenceProviderInterface $provider, + string $stateKey, + ): \ArrayObject { + self::$lastStateByProvider ??= new \WeakMap(); + + /** @var \ArrayObject|null $state */ + $state = self::$lastStateByProvider[$provider] ?? null; + if ($state === null) { + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastStateByProvider[$provider] = $state; + + return $state; + } + + if (!isset($state[$stateKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) { + throw new SnowflakeException('Snowflake provider domain limit exceeded'); + } + + return $state; + } + private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface { return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); @@ -368,12 +412,21 @@ private static function timestampParts(int $timestamp): array return [(string) intdiv($timestamp, 1000), (string) (($timestamp % 1000) * 1000)]; } - private static function waitUntil(int $timestamp): int + private static function waitUntil(int $timestamp, ?GenerationContext $runtime): int { - $now = (int) floor(microtime(true) * 1000); - while ($now < $timestamp) { - usleep(1000); - $now = (int) floor(microtime(true) * 1000); + $deadline = $runtime?->waitDeadlineNanoseconds() + ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); + + while (($now = self::nowMilliseconds($runtime)) < $timestamp) { + if (hrtime(true) >= $deadline) { + throw new SnowflakeException('Timed out waiting for a valid Snowflake timestamp'); + } + + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } return $now; diff --git a/src/Sonyflake.php b/src/Sonyflake.php index 4b8b002..b269cef 100644 --- a/src/Sonyflake.php +++ b/src/Sonyflake.php @@ -8,9 +8,11 @@ use Exception; use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Enums\ClockBackwardPolicy; +use Infocyph\UID\Enums\SonyflakeFormat; use Infocyph\UID\Exceptions\FileLockException; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\SonyflakeException; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; @@ -22,16 +24,22 @@ final class Sonyflake { use GetSequence; - private const DEFAULT_EPOCH = 1_577_836_800_000; + private const int DEFAULT_EPOCH = 1_577_836_800_000; - private const MACHINE_BITS = 16; + private const int MACHINE_BITS = 16; - private const SEQUENCE_BITS = 8; + private const int MAX_PROVIDER_DOMAINS = 1024; - private const TIMESTAMP_BITS = 39; + private const int SEQUENCE_BITS = 8; - /** @var array */ - private static array $lastWallTimeByDomain = []; + private const int TIMESTAMP_BITS = 39; + + private const int UPSTREAM_DEFAULT_EPOCH = 1_409_529_600_000; + + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastWallTimeByProvider = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into Sonyflake decimal. @@ -40,10 +48,12 @@ final class Sonyflake */ public static function fromBase(string $encoded, int $base): string { - return self::decodeNumeric( + $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBase($encoded, $base, 8), null, ); + + return self::assertDecodedId($id); } /** @@ -53,10 +63,12 @@ public static function fromBase(string $encoded, int $base): string */ public static function fromBytes(string $bytes): string { - return self::decodeNumeric( + $id = self::decodeNumeric( fn(): string => NumericIdCodec::decimalFromBytes($bytes, 8), 'Sonyflake binary data must be exactly 8 bytes', ); + + return self::assertDecodedId($id); } /** @@ -70,8 +82,9 @@ public static function generate(int $machineId = 0): string { return self::generateInternal( $machineId, - self::getStartTimeStamp(), + self::getStartTimeStamp(SonyflakeFormat::UID), ClockBackwardPolicy::WAIT, + format: SonyflakeFormat::UID, ); } @@ -84,9 +97,11 @@ public static function generateWithConfig(SonyflakeConfig $config): string { return self::generateInternal( $config->resolveMachineId(), - $config->resolveCustomEpochMs() ?? self::getStartTimeStamp(), + $config->resolveCustomEpochMs() ?? self::getStartTimeStamp($config->format), $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, + $config->format, ); } @@ -107,9 +122,9 @@ public static function isValid(string $id): bool * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} * @throws Exception */ - public static function parse(string $id): array + public static function parse(string $id, SonyflakeFormat $format = SonyflakeFormat::UID): array { - return self::parseWithEpoch($id, self::getStartTimeStamp()); + return self::parseWithEpoch($id, self::getStartTimeStamp($format), $format); } /** @@ -118,13 +133,16 @@ public static function parse(string $id): array * @return array{time: DateTimeImmutable, sequence: int, machine_id: int} * @throws Exception */ - public static function parseWithEpoch(string $id, int $startTimestamp): array - { + public static function parseWithEpoch( + string $id, + int $startTimestamp, + SonyflakeFormat $format = SonyflakeFormat::UID, + ): 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); + $parts = self::extractParts($id, $startTimestamp, $format); return [ 'time' => new DateTimeImmutable( @@ -166,6 +184,67 @@ public static function toBytes(string $id): string ); } + /** + * @return array{0:int,1:int} + */ + private static function allocateSequence( + SequenceProviderInterface $provider, + int $machineId, + int $startTimestamp, + int $elapsedTime, + ClockBackwardPolicy $policy, + ?GenerationContext $runtime, + SonyflakeFormat $format, + ): array { + $sequenceType = $format === SonyflakeFormat::UID + ? 'sonyflake_' . $startTimestamp + : 'sonyflake_upstream_' . $startTimestamp; + + while (true) { + try { + $allocation = self::sequence($elapsedTime, $machineId, $sequenceType, $provider, $runtime); + } catch (SequenceTimestampException $exception) { + if ($policy === ClockBackwardPolicy::THROW) { + throw new SonyflakeException( + 'Clock moved backwards while generating Sonyflake ID', + 0, + $exception, + ); + } + + $elapsedTime = self::waitUntilElapsed($exception->lastTimestamp, $startTimestamp, $runtime); + + continue; + } + + if ($allocation < 1) { + throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation'); + } + if ($allocation <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) { + return [$elapsedTime, $allocation - 1]; + } + + $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp, $runtime); + } + } + + private static function assertDecodedId(string $id): string + { + if (!self::isValid($id)) { + throw new SonyflakeException('Decoded Sonyflake ID exceeds the supported signed domain'); + } + + return $id; + } + + private static function assertMachineId(int $machineId): void + { + $maximum = -1 ^ (-1 << self::MACHINE_BITS); + if ($machineId < 0 || $machineId > $maximum) { + throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maximum."); + } + } + /** * @param callable():string $operation * @throws SonyflakeException @@ -184,6 +263,13 @@ private static function decodeNumeric(callable $operation, ?string $customMessag */ private static function elapsedTime(int $currentTime, int $startTimestamp): int { + if ($currentTime < $startTimestamp) { + throw new SonyflakeException('Sonyflake epoch must not be in the future'); + } + if ($startTimestamp < $currentTime - (((1 << self::TIMESTAMP_BITS) - 1) * 10 + 9)) { + throw new SonyflakeException('Exceeding the maximum life cycle of the algorithm'); + } + return intdiv($currentTime - $startTimestamp, 10); } @@ -207,8 +293,11 @@ private static function ensureEffectiveRuntime(int $elapsedTime): void /** * @return array{seconds:string,fraction:string,sequence:int,machine_id:int} */ - private static function extractParts(string $id, int $startTimestamp): array - { + private static function extractParts( + string $id, + int $startTimestamp, + SonyflakeFormat $format, + ): array { $numericId = (int) $id; $elapsed = $numericId >> 24; $timestamp = $startTimestamp + ($elapsed * 10); @@ -216,8 +305,12 @@ private static function extractParts(string $id, int $startTimestamp): array return [ 'seconds' => (string) intdiv($timestamp, 1000), 'fraction' => (string) (($timestamp % 1000) * 1000), - 'sequence' => $numericId & 0xff, - 'machine_id' => ($numericId >> 8) & 0xffff, + 'sequence' => $format === SonyflakeFormat::UPSTREAM + ? ($numericId >> 16) & 0xff + : $numericId & 0xff, + 'machine_id' => $format === SonyflakeFormat::UPSTREAM + ? $numericId & 0xffff + : ($numericId >> 8) & 0xffff, ]; } @@ -229,77 +322,96 @@ private static function generateInternal( int $startTimestamp, ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, + ?GenerationContext $runtime = null, + SonyflakeFormat $format = SonyflakeFormat::UID, ): string { - $maxMachineID = -1 ^ (-1 << self::MACHINE_BITS); - if ($machineId < 0 || $machineId > $maxMachineID) { - throw new SonyflakeException("Invalid machine ID, must be between 0 ~ $maxMachineID."); - } - - $resolvedSequenceProvider = self::resolveSequenceProvider($sequenceProvider); - $currentTime = (int) floor(microtime(true) * 1000); - $domainKey = $startTimestamp . ':' . $machineId . ':' . spl_object_id($resolvedSequenceProvider); - $lastWallTime = self::$lastWallTimeByDomain[$domainKey] ?? 0; - if ($currentTime < $lastWallTime) { - if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { - throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); - } - - $currentTime = self::waitUntilWallTime($lastWallTime); - } - + self::assertMachineId($machineId); + + $provider = self::resolveSequenceProvider($sequenceProvider); + $domainKey = $format->value . ':' . $startTimestamp . ':' . $machineId; + $providerState = self::providerState($provider, $domainKey); + $currentTime = self::resolveWallTime( + self::nowMilliseconds($runtime), + $providerState[$domainKey] ?? 0, + $clockBackwardPolicy, + $runtime, + ); $elapsedTime = self::elapsedTime($currentTime, $startTimestamp); self::ensureEffectiveRuntime($elapsedTime); - $sequenceType = 'sonyflake_' . $startTimestamp; - - while (true) { - try { - $sequence = self::sequence( - $elapsedTime, - $machineId, - $sequenceType, - $resolvedSequenceProvider, - ); - } 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; - } + [$elapsedTime, $sequence] = self::allocateSequence( + $provider, + $machineId, + $startTimestamp, + $elapsedTime, + $clockBackwardPolicy, + $runtime, + $format, + ); + $providerState[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); + self::ensureEffectiveRuntime($elapsedTime); - if ($sequence < 1) { - throw new SonyflakeException('Sonyflake sequence provider must return a positive allocation'); - } + return self::packId($elapsedTime, $machineId, $sequence, $format); + } - if ($sequence <= (-1 ^ (-1 << self::SEQUENCE_BITS)) + 1) { - --$sequence; + /** + * Retrieves the start timestamp. + */ + private static function getStartTimeStamp(SonyflakeFormat $format): int + { + return $format === SonyflakeFormat::UPSTREAM + ? self::UPSTREAM_DEFAULT_EPOCH + : self::DEFAULT_EPOCH; + } - break; - } + private static function nowMilliseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMilliseconds() ?? (int) floor(microtime(true) * 1000); + } - $elapsedTime = self::waitUntilElapsed($elapsedTime, $startTimestamp); + private static function packId( + int $elapsedTime, + int $machineId, + int $sequence, + SonyflakeFormat $format, + ): string { + if ($format === SonyflakeFormat::UPSTREAM) { + return (string) ( + ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS)) + | ($sequence << self::MACHINE_BITS) + | $machineId + ); } - self::$lastWallTimeByDomain[$domainKey] = max($currentTime, $startTimestamp + ($elapsedTime * 10)); - self::ensureEffectiveRuntime($elapsedTime); - - return (string) ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS) + return (string) ( + ($elapsedTime << (self::MACHINE_BITS + self::SEQUENCE_BITS)) | ($machineId << self::SEQUENCE_BITS) - | ($sequence)); + | $sequence + ); } /** - * Retrieves the start timestamp. + * @return \ArrayObject */ - private static function getStartTimeStamp(): int - { - return self::DEFAULT_EPOCH; + private static function providerState( + SequenceProviderInterface $provider, + string $domainKey, + ): \ArrayObject { + self::$lastWallTimeByProvider ??= new \WeakMap(); + + /** @var \ArrayObject|null $state */ + $state = self::$lastWallTimeByProvider[$provider] ?? null; + if ($state === null) { + /** @var \ArrayObject $state */ + $state = new \ArrayObject(); + self::$lastWallTimeByProvider[$provider] = $state; + } + + if (!isset($state[$domainKey]) && count($state) >= self::MAX_PROVIDER_DOMAINS) { + throw new SonyflakeException('Sonyflake provider domain limit exceeded'); + } + + return $state; } private static function resolveSequenceProvider(?SequenceProviderInterface $provider): SequenceProviderInterface @@ -307,23 +419,61 @@ private static function resolveSequenceProvider(?SequenceProviderInterface $prov return $provider ?? self::$sequenceProvider ??= new FilesystemSequenceProvider(); } - private static function waitUntilElapsed(int $elapsedTime, int $startTimestamp): int - { - $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); - while ($next <= $elapsedTime) { - usleep(1000); - $next = self::elapsedTime((int) floor(microtime(true) * 1000), $startTimestamp); + private static function resolveWallTime( + int $currentTime, + int $lastWallTime, + ClockBackwardPolicy $policy, + ?GenerationContext $runtime, + ): int { + if ($currentTime >= $lastWallTime) { + return $currentTime; + } + if ($policy === ClockBackwardPolicy::THROW) { + throw new SonyflakeException('Clock moved backwards while generating Sonyflake ID'); + } + + return self::waitUntilWallTime($lastWallTime, $runtime); + } + + private static function waitUntilElapsed( + int $elapsedTime, + int $startTimestamp, + ?GenerationContext $runtime, + ): int { + $deadline = $runtime?->waitDeadlineNanoseconds() + ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); + + while (($next = self::elapsedTime(self::nowMilliseconds($runtime), $startTimestamp)) <= $elapsedTime) { + if (hrtime(true) >= $deadline) { + throw new SonyflakeException('Timed out waiting for the next Sonyflake timestamp'); + } + + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } return $next; } - private static function waitUntilWallTime(int $lastTime): int + private static function waitUntilWallTime(int $lastTime, ?GenerationContext $runtime): int { - do { - usleep(1000); - $currentTime = (int) floor(microtime(true) * 1000); - } while ($currentTime < $lastTime); + $deadline = $runtime?->waitDeadlineNanoseconds() + ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); + + while (($currentTime = self::nowMilliseconds($runtime)) < $lastTime) { + if (hrtime(true) >= $deadline) { + throw new SonyflakeException('Timed out waiting for the Sonyflake clock to recover'); + } + + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } + } return $currentTime; } diff --git a/src/Support/BaseEncoder.php b/src/Support/BaseEncoder.php index 87299b3..384839b 100644 --- a/src/Support/BaseEncoder.php +++ b/src/Support/BaseEncoder.php @@ -8,7 +8,7 @@ final class BaseEncoder { - private const ALPHABETS = [ + private const array ALPHABETS = [ 10 => '0123456789', 16 => '0123456789abcdef', 32 => '0123456789abcdefghijklmnopqrstuv', @@ -17,17 +17,22 @@ final class BaseEncoder 62 => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', ]; - private const MAX_BYTE_LENGTH = 1024; + private const int MAX_BYTE_LENGTH = 1024; + + /** @var array */ + private const array RADIX_GROUPS = [ + 10 => [1_000_000_000, 9], + 32 => [1_073_741_824, 6], + 36 => [60_466_176, 5], + 58 => [656_356_768, 5], + 62 => [916_132_832, 5], + ]; - /** - * Decodes one of supported bases (16/32/36/58/62) into bytes. - */ public static function decodeToBytes(string $encoded, int $base, int $bytesLength): string { if ($encoded === '') { throw new InvalidArgumentException('Encoded value must not be empty'); } - if ($bytesLength < 1 || $bytesLength > self::MAX_BYTE_LENGTH) { throw new InvalidArgumentException('Byte length must be between 1 and 1024'); } @@ -43,23 +48,76 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt return $decoded; } + return self::decodeRadix($encoded, $base, $bytesLength); + } + + public static function encodeBytes(string $bytes, int $base): string + { + self::assertByteLength(strlen($bytes)); + + if ($base === 16) { + return ltrim(bin2hex($bytes), '0') ?: '0'; + } + + $alphabet = self::alphabet($base); + [$radix, $width] = self::RADIX_GROUPS[$base]; + $padding = (4 - strlen($bytes) % 4) % 4; + $number = BinaryUnpack::words(str_repeat("\0", $padding) . $bytes); + $encoded = ''; + while ($number !== []) { + $quotient = []; + $remainder = 0; + + foreach ($number as $word) { + // Each radix is at most 2^30, keeping the combined value below 2^62. + $value = ($remainder << 32) | $word; + $digit = intdiv($value, $radix); + $remainder = $value % $radix; + + if ($quotient !== [] || $digit !== 0) { + $quotient[] = $digit; + } + } + + $chunk = self::encodeGroup($remainder, $base, $alphabet); + $encoded = ($quotient === [] ? $chunk : str_pad($chunk, $width, $alphabet[0], STR_PAD_LEFT)) . $encoded; + $number = $quotient; + } + + return $encoded; + } + + private static function alphabet(int $base): string + { + return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); + } + + private static function assertByteLength(int $byteLength): void + { + if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); + } + } + + private static function decodeRadix(string $encoded, int $base, int $bytesLength): string + { $alphabet = self::alphabet($base); - $maxEncodedLength = (int) ceil(($bytesLength * 8) / log($base, 2)); - if (strlen($encoded) > $maxEncodedLength) { + $maximumLength = (int) ceil(($bytesLength * 8) / log($base, 2)); + if (strlen($encoded) > $maximumLength) { throw new InvalidArgumentException('Encoded value exceeds target byte length'); } $bytes = [0]; - $encodedLength = strlen($encoded); + $length = 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); + for ($index = 0; $index < $length; ++$index) { + $digit = strpos($alphabet, $encoded[$index]); + if ($digit === false) { + throw new InvalidArgumentException('Invalid character for base ' . $base); + } - $carry = $alphabetIndex; - $byteCount = count($bytes); - for ($byteIndex = $byteCount - 1; $byteIndex >= 0; --$byteIndex) { + $carry = $digit; + for ($byteIndex = count($bytes) - 1; $byteIndex >= 0; --$byteIndex) { $value = ($bytes[$byteIndex] * $base) + $carry; $bytes[$byteIndex] = $value & 0xff; $carry = $value >> 8; @@ -83,55 +141,14 @@ public static function decodeToBytes(string $encoded, int $base, int $bytesLengt return str_repeat("\0", $bytesLength - strlen($decoded)) . $decoded; } - /** - * Encodes bytes into one of supported bases (16/32/36/58/62). - */ - public static function encodeBytes(string $bytes, int $base): string + private static function encodeGroup(int $value, int $base, string $alphabet): string { - $byteLength = strlen($bytes); - if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new InvalidArgumentException('Byte length must be between 1 and 1024'); - } - - if ($base === 16) { - return ltrim(bin2hex($bytes), '0') ?: '0'; - } - - $alphabet = self::alphabet($base); - $unpacked = unpack('C*', $bytes); - $unpacked !== false || throw new \LogicException('Unable to unpack byte value'); - $number = []; - foreach ($unpacked as $byte) { - is_int($byte) || throw new \LogicException('Unable to unpack byte value'); - $number[] = $byte; - } - - if (trim($bytes, "\0") === '') { - return $alphabet[0]; - } - $encoded = ''; - while ($number !== []) { - $quotient = []; - $remainder = 0; - foreach ($number as $byte) { - $value = ($remainder << 8) | $byte; - $digit = intdiv($value, $base); - $remainder = $value % $base; - if ($quotient !== [] || $digit !== 0) { - $quotient[] = $digit; - } - } - - $encoded = $alphabet[$remainder] . $encoded; - $number = $quotient; - } + do { + $encoded = $alphabet[$value % $base] . $encoded; + $value = intdiv($value, $base); + } while ($value > 0); return $encoded; } - - private static function alphabet(int $base): string - { - return self::ALPHABETS[$base] ?? throw new InvalidArgumentException('Unsupported base: ' . $base); - } } diff --git a/src/Support/BinaryUnpack.php b/src/Support/BinaryUnpack.php index ec4e1e0..d2e3650 100644 --- a/src/Support/BinaryUnpack.php +++ b/src/Support/BinaryUnpack.php @@ -6,6 +6,18 @@ final class BinaryUnpack { + /** + * @return list + */ + public static function bytes(string $bytes): array + { + /** @var array|false $unpacked */ + $unpacked = unpack('C*', $bytes); + $unpacked !== false || throw new \LogicException('Unable to unpack byte value'); + + return array_values($unpacked); + } + /** * @throws \Exception */ @@ -30,6 +42,18 @@ public static function u32(string $bytes, string $error): int return self::value(unpack('N', $bytes), $error); } + /** + * @return list + */ + public static function words(string $bytes): array + { + /** @var array|false $unpacked */ + $unpacked = unpack('N*', $bytes); + $unpacked !== false || throw new \LogicException('Unable to unpack word value'); + + return array_values($unpacked); + } + /** * @param array|false $unpacked * @throws \Exception diff --git a/src/Support/DecimalBytes.php b/src/Support/DecimalBytes.php index b433fb1..f2e76c4 100644 --- a/src/Support/DecimalBytes.php +++ b/src/Support/DecimalBytes.php @@ -4,28 +4,124 @@ namespace Infocyph\UID\Support; +use InvalidArgumentException; +use LogicException; + final class DecimalBytes { - private const MAX_BYTE_LENGTH = 1024; + private const int MAX_BYTE_LENGTH = 1024; + + private const string MAX_SIGNED_64 = '9223372036854775807'; + + private const string MAX_UNSIGNED_64 = '18446744073709551615'; + + private const string MIN_SIGNED_64_MAGNITUDE = '9223372036854775808'; + + private const string TWO_TO_64 = '18446744073709551616'; public static function fromBytes(string $bytes): string { return BaseEncoder::encodeBytes($bytes, 10); } + public static function fromLittleEndianSigned64(string $bytes): string + { + if (strlen($bytes) !== 8) { + throw new InvalidArgumentException('Signed 64-bit value must contain exactly 8 bytes'); + } + + $unsigned = self::fromBytes(strrev($bytes)); + if ((ord($bytes[7]) & 0x80) === 0) { + return $unsigned; + } + + return '-' . self::subtract(self::TWO_TO_64, $unsigned); + } + + public static function isSigned64(string $value): bool + { + if ($value === '') { + return false; + } + + if ($value[0] === '-') { + $magnitude = substr($value, 1); + + return $magnitude !== '' + && ctype_digit($magnitude) + && $magnitude[0] !== '0' + && UnsignedDecimal::compare($magnitude, self::MIN_SIGNED_64_MAGNITUDE) <= 0; + } + + return ctype_digit($value) + && ($value === '0' || $value[0] !== '0') + && UnsignedDecimal::compare($value, self::MAX_SIGNED_64) <= 0; + } + /** - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public static function toFixedBytes(string $decimal, int $byteLength): string { if ($byteLength < 1 || $byteLength > self::MAX_BYTE_LENGTH) { - throw new \InvalidArgumentException('Byte length must be between 1 and 1024'); + throw new InvalidArgumentException('Byte length must be between 1 and 1024'); } if ($decimal === '' || !ctype_digit($decimal)) { - throw new \InvalidArgumentException('Decimal value must contain only digits'); + throw new InvalidArgumentException('Decimal value must contain only digits'); } return BaseEncoder::decodeToBytes(UnsignedDecimal::normalize($decimal), 10, $byteLength); } + + public static function toLittleEndianSigned64(string $value): string + { + if (!self::isSigned64($value)) { + throw new InvalidArgumentException('Value is outside the signed 64-bit domain'); + } + + $unsigned = $value[0] === '-' + ? self::subtract(self::TWO_TO_64, substr($value, 1)) + : $value; + + if (UnsignedDecimal::compare($unsigned, self::MAX_UNSIGNED_64) > 0) { + throw new InvalidArgumentException('Value is outside the unsigned 64-bit storage domain'); + } + + return strrev(self::toFixedBytes($unsigned, 8)); + } + + private static function subtract(string $left, string $right): string + { + if (UnsignedDecimal::compare($left, $right) < 0) { + throw new InvalidArgumentException('Unsigned subtraction would become negative'); + } + + $leftIndex = strlen($left) - 1; + $rightIndex = strlen($right) - 1; + $borrow = 0; + $result = ''; + + while ($leftIndex >= 0) { + $digit = (ord($left[$leftIndex]) - 48) - $borrow; + $rightDigit = $rightIndex >= 0 ? ord($right[$rightIndex]) - 48 : 0; + if ($digit < $rightDigit) { + $digit += 10; + $borrow = 1; + } else { + $borrow = 0; + } + + $difference = $digit - $rightDigit; + if ($difference < 0 || $difference > 9) { + throw new LogicException('Signed decimal subtraction produced an invalid digit'); + } + + $result = $difference . $result; + --$leftIndex; + --$rightIndex; + } + + return UnsignedDecimal::normalize($result); + } } diff --git a/src/Support/FileLock.php b/src/Support/FileLock.php index 2daee3e..ad61687 100644 --- a/src/Support/FileLock.php +++ b/src/Support/FileLock.php @@ -4,10 +4,14 @@ namespace Infocyph\UID\Support; +use ErrorException; use Infocyph\UID\Exceptions\FileLockException; +use Infocyph\UID\Runtime\GenerationContext; final class FileLock { + private const int DEFAULT_TIMEOUT_MICROS = 1_000_000; + /** * @return resource * @throws FileLockException @@ -17,37 +21,162 @@ public static function acquire( ?int $timeoutMicros, string $openErrorMessage, string $lockErrorMessage, + ?GenerationContext $runtime = null, ) { - ($handle = fopen($path, 'c+')) || throw new FileLockException($openErrorMessage); - - if ($timeoutMicros === null) { - if (flock($handle, LOCK_EX)) { - return $handle; - } - + $runtime?->assertActive(); + $handle = self::openVerified($path, $openErrorMessage); + $wouldBlock = 0; + if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { + return $handle; + } + if ($wouldBlock !== 1) { fclose($handle); throw new FileLockException($lockErrorMessage); } - $deadline = hrtime(true) + ($timeoutMicros * 1000); - do { - $wouldBlock = 0; - if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { - return $handle; - } + try { + $deadline = self::lockDeadline($timeoutMicros, $runtime); - if ($wouldBlock !== 1) { - fclose($handle); + while (hrtime(true) < $deadline) { + $wouldBlock = 0; + if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { + return $handle; + } + if ($wouldBlock !== 1) { + throw new FileLockException($lockErrorMessage); + } - throw new FileLockException($lockErrorMessage); + if ($runtime !== null) { + $runtime->sleepMicroseconds(1_000); + } else { + usleep(1_000); + } } + } catch (\Throwable $exception) { + fclose($handle); - usleep(1000); - } while (hrtime(true) < $deadline); + throw $exception; + } fclose($handle); throw new FileLockException($lockErrorMessage); } + + /** + * @param array $metadata + * @throws FileLockException + */ + private static function assertSafeMetadata(array $metadata, string $errorMessage, ?int $ownerId): void + { + if (($metadata['mode'] & 0170000) !== 0100000) { + throw new FileLockException($errorMessage); + } + + if ($ownerId !== null && $metadata['uid'] !== $ownerId) { + throw new FileLockException($errorMessage); + } + } + + private static function lockDeadline(?int $timeoutMicros, ?GenerationContext $runtime): int + { + $timeout = $timeoutMicros ?? $runtime->waitTimeoutMicros ?? self::DEFAULT_TIMEOUT_MICROS; + if ($runtime !== null) { + $timeout = min($timeout, $runtime->waitTimeoutMicros); + } + $now = hrtime(true); + $deadline = $now + (min($timeout, intdiv(PHP_INT_MAX - $now, 1_000)) * 1_000); + $runtimeDeadline = $runtime?->runwire?->deadlineNanoseconds(); + + return $runtimeDeadline === null ? $deadline : min($deadline, $runtimeDeadline); + } + + /** + * @return resource + * @throws FileLockException + */ + private static function openVerified(string $path, string $errorMessage) + { + $ownerId = function_exists('posix_geteuid') ? posix_geteuid() : null; + set_error_handler( + static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, + ); + + try { + // Refresh metadata; handle identity checks also fence cached path resolution. + clearstatcache(); + + try { + $before = lstat($path); + } catch (ErrorException) { + $before = false; + } + + if ($before !== false) { + self::assertSafeMetadata($before, $errorMessage, $ownerId); + $handle = fopen($path, 'r+b'); + is_resource($handle) || throw new FileLockException($errorMessage); + + return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId); + } + + try { + $handle = fopen($path, 'x+b'); + } catch (ErrorException) { + $before = lstat($path); + $before !== false || throw new FileLockException($errorMessage); + self::assertSafeMetadata($before, $errorMessage, $ownerId); + + $handle = fopen($path, 'r+b'); + is_resource($handle) || throw new FileLockException($errorMessage); + + return self::verifyHandle($path, $handle, $before, $errorMessage, $ownerId); + } + + is_resource($handle) || throw new FileLockException($errorMessage); + chmod($path, 0600) || throw new FileLockException($errorMessage); + + return self::verifyHandle($path, $handle, null, $errorMessage, $ownerId); + } catch (ErrorException $exception) { + throw new FileLockException($errorMessage, 0, $exception); + } finally { + restore_error_handler(); + } + } + + /** + * @param resource $handle + * @param array|null $before + * @return resource + */ + private static function verifyHandle(string $path, $handle, ?array $before, string $errorMessage, ?int $ownerId) + { + try { + $after = fstat($handle); + clearstatcache(); + $pathState = lstat($path); + if ($after === false || $pathState === false) { + throw new FileLockException($errorMessage); + } + + if ( + ($after['mode'] & 0170000) !== 0100000 + || ($pathState['mode'] & 0170000) !== 0100000 + || ($ownerId !== null && ($after['uid'] !== $ownerId || $pathState['uid'] !== $ownerId)) + || $after['dev'] !== $pathState['dev'] || $after['ino'] !== $pathState['ino'] + || ($before !== null && ($before['dev'] !== $after['dev'] || $before['ino'] !== $after['ino'])) + ) { + throw new FileLockException($errorMessage); + } + + return $handle; + } catch (\Throwable $exception) { + fclose($handle); + + throw $exception; + } + } } diff --git a/src/Support/GetSequence.php b/src/Support/GetSequence.php index 0087315..e94b37b 100644 --- a/src/Support/GetSequence.php +++ b/src/Support/GetSequence.php @@ -4,6 +4,7 @@ namespace Infocyph\UID\Support; +use Infocyph\UID\Runtime\GenerationContext; use Infocyph\UID\Sequence\CallbackSequenceProvider; use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\InMemorySequenceProvider; @@ -39,12 +40,14 @@ public static function useFilesystemSequenceProvider( string $namespace = '', ?int $lockTimeoutMicros = null, int $reservationSize = 1, + ?GenerationContext $runtime = null, ): void { self::$sequenceProvider = new FilesystemSequenceProvider( $baseDirectory, $namespace, $lockTimeoutMicros, $reservationSize, + $runtime, ); } @@ -75,6 +78,7 @@ public static function useSimpleCacheSequenceProvider( ?callable $synchronizer = null, int $waitTime = 1_000, int $maxAttempts = 1_000, + ?GenerationContext $runtime = null, ): void { self::$sequenceProvider = new PsrSimpleCacheSequenceProvider( $cache, @@ -82,6 +86,7 @@ public static function useSimpleCacheSequenceProvider( $waitTime, $maxAttempts, $synchronizer, + $runtime, ); } @@ -96,9 +101,12 @@ private static function sequence( int $dateTime, int $machineId, string $type, - ?SequenceProviderInterface $provider = null, + SequenceProviderInterface $provider, + ?GenerationContext $runtime = null, ): int { - $provider ??= self::$sequenceProvider ??= new FilesystemSequenceProvider(); + if ($runtime !== null && ($provider instanceof FilesystemSequenceProvider || $provider instanceof PsrSimpleCacheSequenceProvider)) { + return $provider->next($type, $machineId, $dateTime, $runtime); + } return $provider->next($type, $machineId, $dateTime); } diff --git a/src/Support/Sparx64.php b/src/Support/Sparx64.php new file mode 100644 index 0000000..78f6705 --- /dev/null +++ b/src/Support/Sparx64.php @@ -0,0 +1,165 @@ +> */ + private array $subkeys; + + public function __construct(#[\SensitiveParameter] string $key) + { + if (strlen($key) !== 16) { + throw new InvalidArgumentException('SPARX64 key must be exactly 16 bytes'); + } + + $master = []; + for ($index = 0; $index < 16; $index += 2) { + $master[] = (ord($key[$index]) << 8) | ord($key[$index + 1]); + } + + $this->subkeys = []; + for ($counter = 0; $counter < (self::BRANCHES * self::STEPS) + 1; ++$counter) { + $this->subkeys[$counter] = array_slice($master, 0, 2 * self::ROUNDS_PER_STEP); + self::permuteKey($master, $counter + 1); + } + } + + public function decrypt(string $block): string + { + $state = self::unpackBlock($block); + $last = self::BRANCHES * self::STEPS; + for ($branch = 0; $branch < self::BRANCHES; ++$branch) { + $state[2 * $branch] ^= $this->subkeys[$last][2 * $branch]; + $state[(2 * $branch) + 1] ^= $this->subkeys[$last][(2 * $branch) + 1]; + } + + for ($step = self::STEPS - 1; $step >= 0; --$step) { + self::linearInverse($state); + for ($branch = 0; $branch < self::BRANCHES; ++$branch) { + for ($round = self::ROUNDS_PER_STEP - 1; $round >= 0; --$round) { + self::roundInverse($state[2 * $branch], $state[(2 * $branch) + 1]); + $state[2 * $branch] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][2 * $round]; + $state[(2 * $branch) + 1] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][(2 * $round) + 1]; + } + } + } + + return self::packBlock($state); + } + + public function encrypt(string $block): string + { + $state = self::unpackBlock($block); + for ($step = 0; $step < self::STEPS; ++$step) { + for ($branch = 0; $branch < self::BRANCHES; ++$branch) { + for ($round = 0; $round < self::ROUNDS_PER_STEP; ++$round) { + $state[2 * $branch] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][2 * $round]; + $state[(2 * $branch) + 1] ^= $this->subkeys[(self::BRANCHES * $step) + $branch][(2 * $round) + 1]; + self::round($state[2 * $branch], $state[(2 * $branch) + 1]); + } + } + + self::linear($state); + } + + $last = self::BRANCHES * self::STEPS; + for ($branch = 0; $branch < self::BRANCHES; ++$branch) { + $state[2 * $branch] ^= $this->subkeys[$last][2 * $branch]; + $state[(2 * $branch) + 1] ^= $this->subkeys[$last][(2 * $branch) + 1]; + } + + return self::packBlock($state); + } + + /** @param array $state */ + private static function linear(array &$state): void + { + $temporary = self::rotateLeft16($state[0] ^ $state[1], 8); + $state[2] ^= $state[0] ^ $temporary; + $state[3] ^= $state[1] ^ $temporary; + [$state[0], $state[2]] = [$state[2] & 0xffff, $state[0] & 0xffff]; + [$state[1], $state[3]] = [$state[3] & 0xffff, $state[1] & 0xffff]; + } + + /** @param array $state */ + private static function linearInverse(array &$state): void + { + [$state[0], $state[2]] = [$state[2], $state[0]]; + [$state[1], $state[3]] = [$state[3], $state[1]]; + $temporary = self::rotateLeft16($state[0] ^ $state[1], 8); + $state[2] = ($state[2] ^ $state[0] ^ $temporary) & 0xffff; + $state[3] = ($state[3] ^ $state[1] ^ $temporary) & 0xffff; + } + + /** @param array $state */ + private static function packBlock(array $state): string + { + $output = ''; + foreach ($state as $value) { + $output .= chr(($value >> 8) & 0xff) . chr($value & 0xff); + } + + return $output; + } + + /** @param array $key */ + private static function permuteKey(array &$key, int $counter): void + { + self::round($key[0], $key[1]); + $key[2] = ($key[2] + $key[0]) & 0xffff; + $key[3] = ($key[3] + $key[1]) & 0xffff; + $key[7] = ($key[7] + $counter) & 0xffff; + $six = $key[6]; + $seven = $key[7]; + for ($index = 7; $index >= 2; --$index) { + $key[$index] = $key[$index - 2]; + } + $key[0] = $six; + $key[1] = $seven; + } + + private static function rotateLeft16(int $value, int $bits): int + { + $value &= 0xffff; + + return (($value << $bits) | ($value >> (16 - $bits))) & 0xffff; + } + + private static function round(int &$left, int &$right): void + { + $left = (self::rotateLeft16($left, 9) + $right) & 0xffff; + $right = (self::rotateLeft16($right, 2) ^ $left) & 0xffff; + } + + private static function roundInverse(int &$left, int &$right): void + { + $right = self::rotateLeft16($right ^ $left, 14); + $left = self::rotateLeft16(($left - $right) & 0xffff, 7); + } + + /** @return array */ + private static function unpackBlock(string $block): array + { + if (strlen($block) !== 8) { + throw new InvalidArgumentException('SPARX64 block must be exactly 8 bytes'); + } + + return [ + (ord($block[0]) << 8) | ord($block[1]), + (ord($block[2]) << 8) | ord($block[3]), + (ord($block[4]) << 8) | ord($block[5]), + (ord($block[6]) << 8) | ord($block[7]), + ]; + } +} diff --git a/src/Support/TypeIdCodec.php b/src/Support/TypeIdCodec.php index e0f70a2..9c6e943 100644 --- a/src/Support/TypeIdCodec.php +++ b/src/Support/TypeIdCodec.php @@ -8,7 +8,7 @@ final class TypeIdCodec { - private const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; + private const string ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; public static function decode(string $suffix): string { diff --git a/src/TBSL.php b/src/TBSL.php index a8fdfa8..45ecde4 100644 --- a/src/TBSL.php +++ b/src/TBSL.php @@ -10,6 +10,8 @@ use Infocyph\UID\Enums\ClockBackwardPolicy; use Infocyph\UID\Exceptions\SequenceTimestampException; use Infocyph\UID\Exceptions\UIDException; +use Infocyph\UID\Runtime\GenerationContext; +use Infocyph\UID\Sequence\FilesystemSequenceProvider; use Infocyph\UID\Sequence\SequenceProviderInterface; use Infocyph\UID\Support\BaseEncoder; use Infocyph\UID\Support\GetSequence; @@ -18,7 +20,10 @@ final class TBSL { use GetSequence; - private static int $lastTimeSequence = 0; + private const int WAIT_TIMEOUT_MICROS = 1_000_000; + + /** @var \WeakMap>|null */ + private static ?\WeakMap $lastTimeByProvider = null; /** * Decodes one of bases: 16, 32, 36, 58, 62 into canonical TBSL. @@ -78,6 +83,7 @@ public static function generateWithConfig(TBSLConfig $config): string $config->sequenced, $config->clockBackwardPolicy, $config->sequenceProvider, + $config->runtime, ); } @@ -86,7 +92,7 @@ public static function generateWithConfig(TBSLConfig $config): string */ public static function isValid(string $tbsl): bool { - return (bool) preg_match('/^[0-9A-F]{20}$/', $tbsl); + return (bool) preg_match('/^[0-9A-F]{20}$/D', $tbsl); } /** @@ -107,11 +113,11 @@ public static function parse(string $tbsl): array $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); + $time = intdiv($storeValue, 100); return [ - 'time' => new DateTimeImmutable('@' . substr($storeData, 0, 10) . '.' . substr($storeData, 10, 6)), - 'machineId' => (int) substr($storeData, -2), + 'time' => new DateTimeImmutable('@' . intdiv($time, 1_000_000) . '.' . str_pad((string) ($time % 1_000_000), 6, '0', STR_PAD_LEFT)), + 'machineId' => $storeValue % 100, ]; } @@ -152,6 +158,13 @@ private static function assertMachineId(int $machineId): void } } + private static function assertTimestamp(int $timestamp, int $machineId): void + { + if ($timestamp < 0 || $timestamp > intdiv(0x0fffffffffffffff - $machineId, 100)) { + throw new UIDException('TBSL timestamp exceeds its 60-bit field'); + } + } + /** * @throws Exception */ @@ -160,29 +173,37 @@ private static function generateInternal( bool $sequenced, ClockBackwardPolicy $clockBackwardPolicy, ?SequenceProviderInterface $sequenceProvider = null, + ?GenerationContext $runtime = null, ): string { self::assertMachineId($machineId); - [$micro, $seconds] = explode(' ', microtime()); - $timeSequence = (int) ($seconds . substr($micro, 2, 6)); + $sequenceProvider ??= self::$sequenceProvider ??= new FilesystemSequenceProvider(); + self::$lastTimeByProvider ??= new \WeakMap(); + /** @var \ArrayObject $state */ + $state = self::$lastTimeByProvider[$sequenceProvider] ??= new \ArrayObject(); + $lastTime = $state[$machineId] ?? 0; + $timeSequence = self::nowMicroseconds($runtime); - if ($timeSequence < self::$lastTimeSequence) { + if ($timeSequence < $lastTime) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new UIDException('Clock moved backwards while generating TBSL ID'); } - $timeSequence = self::waitUntilNextTimeSequence(self::$lastTimeSequence); + $timeSequence = self::waitUntilNextTimeSequence($lastTime, $runtime); } + self::assertTimestamp($timeSequence, $machineId); [$timeSequence, $tail] = self::resolveTail( $machineId, $sequenced, $timeSequence, $clockBackwardPolicy, $sequenceProvider, + $runtime, ); - self::$lastTimeSequence = $timeSequence; + self::assertTimestamp($timeSequence, $machineId); + $state[$machineId] = max($state[$machineId] ?? 0, $timeSequence); - $storeValue = (int) ($timeSequence . sprintf('%02d', $machineId)); + $storeValue = ($timeSequence * 100) + $machineId; $storeData = ltrim(bin2hex(pack('J', $storeValue)), '0'); if (strlen($storeData) > 15) { throw new UIDException('TBSL timestamp exceeds its 60-bit field'); @@ -195,6 +216,11 @@ private static function generateInternal( )); } + private static function nowMicroseconds(?GenerationContext $runtime): int + { + return $runtime?->nowMicroseconds() ?? (int) floor(microtime(true) * 1_000_000); + } + /** * Generates a sequence or random bytes based on the sequencing flag. * @@ -209,7 +235,8 @@ private static function resolveTail( bool $enableSequence, int $timeSequence, ClockBackwardPolicy $clockBackwardPolicy, - ?SequenceProviderInterface $sequenceProvider = null, + SequenceProviderInterface $sequenceProvider, + ?GenerationContext $runtime = null, ): array { if (!$enableSequence) { return [$timeSequence, substr(bin2hex(random_bytes(3)), 0, 5)]; @@ -217,7 +244,7 @@ private static function resolveTail( do { try { - $sequence = self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider); + $sequence = self::sequence($timeSequence, $machineId, 'tbsl', $sequenceProvider, $runtime); } catch (SequenceTimestampException $exception) { if ($clockBackwardPolicy === ClockBackwardPolicy::THROW) { throw new UIDException( @@ -227,7 +254,7 @@ private static function resolveTail( ); } - $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp); + $timeSequence = self::waitUntilNextTimeSequence($exception->lastTimestamp, $runtime); continue; } @@ -240,16 +267,26 @@ private static function resolveTail( return [$timeSequence, str_pad(dechex($sequence - 1), 5, '0', STR_PAD_LEFT)]; } - $timeSequence = self::waitUntilNextTimeSequence($timeSequence); + $timeSequence = self::waitUntilNextTimeSequence($timeSequence, $runtime); } while (true); } - private static function waitUntilNextTimeSequence(int $last): int + private static function waitUntilNextTimeSequence(int $last, ?GenerationContext $runtime): int { - do { - [$micro, $seconds] = explode(' ', microtime()); - $candidate = (int) ($seconds . substr($micro, 2, 6)); - } while ($candidate <= $last); + $deadline = $runtime?->waitDeadlineNanoseconds() + ?? hrtime(true) + (self::WAIT_TIMEOUT_MICROS * 1_000); + + while (($candidate = self::nowMicroseconds($runtime)) <= $last) { + if (hrtime(true) >= $deadline) { + throw new UIDException('Timed out waiting for the next TBSL timestamp'); + } + + if ($runtime !== null) { + $runtime->sleepMicroseconds(100); + } else { + usleep(100); + } + } return $candidate; } diff --git a/src/ULID.php b/src/ULID.php index dafccde..a5f027a 100644 --- a/src/ULID.php +++ b/src/ULID.php @@ -13,15 +13,15 @@ final class ULID { - private const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + private const string ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - private const ENCODING_LENGTH = 32; + private const int ENCODING_LENGTH = 32; - private const MAX_TIMESTAMP = 281_474_976_710_655; + private const int MAX_TIMESTAMP = 281_474_976_710_655; - private const RANDOM_LENGTH = 16; + private const int RANDOM_LENGTH = 16; - private const TIME_LENGTH = 10; + private const int TIME_LENGTH = 10; private static int $lastGenTime = 0; @@ -87,17 +87,19 @@ public static function generate( self::assertTimestamp($time); $isMonotonic = $mode === UlidGenerationMode::MONOTONIC; - if ($isMonotonic && $dateTime === null && $time < self::$lastGenTime) { - $time = self::$lastGenTime; + if (!$isMonotonic) { + return self::encodeTime($time) . self::randomChars(); } - $isDuplicate = $isMonotonic && $time === self::$lastGenTime; - if ($isMonotonic) { - self::$lastGenTime = $time; + if ($dateTime === null && $time < self::$lastGenTime) { + $time = self::$lastGenTime; } + $isDuplicate = $time === self::$lastGenTime; + self::$lastGenTime = $time; + $timeChars = self::encodeTime($time); - if (!$isMonotonic || !$isDuplicate || count(self::$lastRandChars) !== self::RANDOM_LENGTH) { + if (!$isDuplicate || count(self::$lastRandChars) !== self::RANDOM_LENGTH) { self::resetRandomState(); } elseif (!self::incrementRandomState()) { if ($dateTime !== null) { @@ -105,6 +107,7 @@ public static function generate( } $time = self::waitForNextMillisecond(self::$lastGenTime); + self::assertTimestamp($time); self::$lastGenTime = $time; $timeChars = self::encodeTime($time); self::resetRandomState(); @@ -168,7 +171,7 @@ public static function getTime(string $ulid): DateTimeImmutable */ public static function isValid(string $ulid): bool { - return (bool) preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/', $ulid); + return (bool) preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/D', $ulid); } /** @@ -236,19 +239,43 @@ private static function encodeTime(int $time): string private static function incrementRandomState(): bool { + $next = self::$lastRandChars; for ($index = self::RANDOM_LENGTH - 1; $index >= 0; --$index) { - if (self::$lastRandChars[$index] < 31) { - self::$lastRandChars[$index]++; + if ($next[$index] < 31) { + $next[$index]++; + self::$lastRandChars = $next; return true; } - self::$lastRandChars[$index] = 0; + $next[$index] = 0; } return false; } + /** + * @throws Exception + */ + private static function randomChars(): string + { + $random = random_bytes(10); + $result = ''; + $buffer = 0; + $bits = 0; + for ($index = 0; $index < 10; ++$index) { + $buffer = ($buffer << 8) | ord($random[$index]); + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + $result .= self::ENCODING_CHARS[($buffer >> $bits) & 31]; + $buffer &= $bits === 0 ? 0 : (1 << $bits) - 1; + } + } + + return $result; + } + private static function randomCharsFromState(): string { $randChars = ''; @@ -293,7 +320,14 @@ private static function resetRandomState(): void private static function waitForNextMillisecond(int $lastTimestamp): int { + if ($lastTimestamp >= self::MAX_TIMESTAMP) { + throw new ULIDException('ULID timestamp exhausted'); + } + $deadline = hrtime(true) + 1_000_000_000; do { + if (hrtime(true) >= $deadline) { + throw new ULIDException('Timed out waiting for the next ULID timestamp'); + } usleep(1000); $next = (int) floor(microtime(true) * 1000); } while ($next <= $lastTimestamp); diff --git a/src/UUID.php b/src/UUID.php index 00fefc9..4fef6e7 100644 --- a/src/UUID.php +++ b/src/UUID.php @@ -14,26 +14,26 @@ final class UUID { - private const MAX_V7_TIMESTAMP = 281_474_976_710_655; + private const int MAX_V7_TIMESTAMP = 281_474_976_710_655; - private const NS_LIST = [ + private const array NS_LIST = [ 'dns' => 0, 'url' => 1, 'oid' => 2, 'x500' => 4, ]; - private const RANDOM_LENGTH = [ + private const array RANDOM_LENGTH = [ 6 => 2, 7 => 4, 8 => 1, ]; - private const SECOND_INTERVALS = 10_000_000; + private const int SECOND_INTERVALS = 10_000_000; - private const SECOND_INTERVALS_78 = 10_000; + private const int SECOND_INTERVALS_78 = 10_000; - private const TIME_OFFSET = 0x01b21dd213814000; + private const int TIME_OFFSET = 0x01b21dd213814000; /** @var array */ private static array $subSec = [ @@ -137,7 +137,7 @@ public static function guid(bool $trim = true): string $data[8] = chr(ord($data[8]) & 0x3f | 0x80); $data = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); - return $trim ? $data : "\{$data\}"; + return $trim ? $data : '{' . $data . '}'; } /** @@ -550,41 +550,20 @@ private static function getUnixTimeSubSec(int $version = 1): array * * @return string|null The incremented value or null if overflow occurred. */ - private static function incrementHexCounter(string $hex): ?string + private static function incrementV7Tail(string $tail): ?string { - $hex = strtolower($hex); - for ($index = strlen($hex) - 1; $index >= 0; --$index) { - if ($hex[$index] === 'f') { - $hex[$index] = '0'; + $tail = strtolower($tail); + for ($index = strlen($tail) - 1; $index >= 1; --$index) { + $value = intval($tail[$index], 16); + $maximum = $index === 4 ? 3 : 15; + $value = $index === 4 ? $value & 3 : $value; + if ($value < $maximum) { + $tail[$index] = dechex($value + 1); - continue; + return $tail; } - $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; - } - - $hex[$index] = $next; - - return $hex; + $tail[$index] = '0'; } return null; @@ -659,7 +638,7 @@ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimest } $unixTsMs = $state['timestamp']; - $tail = self::incrementHexCounter($state['tail']); + $tail = self::incrementV7Tail($state['tail']); if ($tail === null) { if ($isExplicitTimestamp) { throw new UUIDException('Monotonic UUID v7 overflow for the provided timestamp'); @@ -679,11 +658,22 @@ private static function nextV7DefaultState(int $unixTsMs, bool $isExplicitTimest */ private static function nextV7Timestamp(int $lastTimestamp): int { + if ($lastTimestamp >= self::MAX_V7_TIMESTAMP) { + throw new UUIDException('UUID v7 timestamp exhausted'); + } + $deadline = hrtime(true) + 1_000_000_000; do { + if (hrtime(true) >= $deadline) { + throw new UUIDException('Timed out waiting for the next UUID v7 timestamp'); + } usleep(1000); $next = (int) floor(microtime(true) * 1000); } while ($next <= $lastTimestamp); + if ($next > self::MAX_V7_TIMESTAMP) { + throw new UUIDException('UUID v7 timestamp exhausted'); + } + return $next; } @@ -714,7 +704,7 @@ private static function normalizeInputToHex(string $uuid): string */ private static function normalizeNode(string $node): string { - if (!preg_match('/^[0-9a-f]{12}$/i', $node)) { + if (!preg_match('/^[0-9a-f]{12}$/iD', $node)) { throw new UUIDException('UUID node must be exactly 12 hexadecimal characters'); } @@ -800,6 +790,10 @@ private static function randomLengthFor(int $version): int */ private static function randomV7Tail(): string { - return bin2hex(random_bytes(self::randomLengthFor(7) + 6)); + $tail = bin2hex(random_bytes(self::randomLengthFor(7) + 6)); + $tail[0] = '0'; + $tail[4] = dechex(hexdec($tail[4]) & 3); + + return $tail; } } diff --git a/src/Value/AbstractParsedIdValue.php b/src/Value/AbstractParsedIdValue.php index e6cb017..0f22796 100644 --- a/src/Value/AbstractParsedIdValue.php +++ b/src/Value/AbstractParsedIdValue.php @@ -5,25 +5,31 @@ namespace Infocyph\UID\Value; use Infocyph\UID\Contracts\IdValueInterface; +use Infocyph\UID\IdComparator; /** * @template TParsed of array */ abstract readonly class AbstractParsedIdValue implements IdValueInterface { - use ComparableIdValue; - /** @var TParsed */ protected array $parsed; + private string $value; + public function __construct(string $value) { - $this->parsed = $this->initializeComparableValue( - $value, - $this->validator(), - $this->parser(), - $this->invalidMessage(), - ); + $validator = $this->validator(); + $validator($value) || throw new \InvalidArgumentException($this->invalidMessage()); + + $parser = $this->parser(); + $this->value = $value; + $this->parsed = $parser($value); + } + + public function __toString(): string + { + return $this->toString(); } abstract protected function invalidMessage(): string; @@ -37,4 +43,26 @@ abstract protected function parser(): callable; * @return callable(string):bool */ abstract protected function validator(): callable; + + public function compare(IdValueInterface|string $other): int + { + $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; + + return IdComparator::compare($this->value, $otherValue); + } + + public function getVersion(): ?int + { + return null; + } + + public function isSortable(): bool + { + return true; + } + + public function toString(): string + { + return $this->value; + } } diff --git a/src/Value/ComparableIdValue.php b/src/Value/ComparableIdValue.php deleted file mode 100644 index adf3890..0000000 --- a/src/Value/ComparableIdValue.php +++ /dev/null @@ -1,58 +0,0 @@ -toString(); - } - - public function compare(IdValueInterface|string $other): int - { - $otherValue = $other instanceof IdValueInterface ? $other->toString() : $other; - - return IdComparator::compare($this->value, $otherValue); - } - - public function getVersion(): ?int - { - return null; - } - - public function isSortable(): bool - { - return true; - } - - public function toString(): string - { - return $this->value; - } - - /** - * @template T of array - * @param callable(string):bool $validator - * @param callable(string):T $parser - * @return T - */ - protected function initializeComparableValue( - string $value, - callable $validator, - callable $parser, - string $invalidMessage, - ): array { - $validator($value) || throw new \InvalidArgumentException($invalidMessage); - $this->value = $value; - - return $parser($value); - } -} diff --git a/src/Value/SonyflakeValue.php b/src/Value/SonyflakeValue.php index a67b11b..ab8c43e 100644 --- a/src/Value/SonyflakeValue.php +++ b/src/Value/SonyflakeValue.php @@ -5,6 +5,7 @@ namespace Infocyph\UID\Value; use DateTimeImmutable; +use Infocyph\UID\Enums\SonyflakeFormat; use Infocyph\UID\Sonyflake; /** @@ -12,11 +13,19 @@ */ final readonly class SonyflakeValue extends AbstractParsedIdValue { - public function __construct(string $value, private ?int $customEpoch = null) - { + public function __construct( + string $value, + private ?int $customEpoch = null, + private SonyflakeFormat $format = SonyflakeFormat::UID, + ) { parent::__construct($value); } + public function getFormat(): SonyflakeFormat + { + return $this->format; + } + public function getMachineId(): int { return $this->parsed['machine_id']; @@ -35,8 +44,8 @@ protected function invalidMessage(): string protected function parser(): callable { return $this->customEpoch === null - ? Sonyflake::parse(...) - : fn(string $id): array => Sonyflake::parseWithEpoch($id, $this->customEpoch); + ? fn(string $id): array => Sonyflake::parse($id, $this->format) + : fn(string $id): array => Sonyflake::parseWithEpoch($id, $this->customEpoch, $this->format); } protected function validator(): callable diff --git a/src/XID.php b/src/XID.php index 92830b0..f57d313 100644 --- a/src/XID.php +++ b/src/XID.php @@ -11,7 +11,7 @@ final class XID { - private const ALPHABET = '0123456789abcdefghijklmnopqrstuv'; + private const string ALPHABET = '0123456789abcdefghijklmnopqrstuv'; private static ?int $counter = null; diff --git a/tests/BaseEncoderTest.php b/tests/BaseEncoderTest.php new file mode 100644 index 0000000..8104c67 --- /dev/null +++ b/tests/BaseEncoderTest.php @@ -0,0 +1,24 @@ +toBe($vector['sha256']) + ->and(BaseEncoder::decodeToBytes($encoded, $vector['base'], strlen($bytes)))->toBe($bytes); + } +}); + +test('radix encoding retains input bounds and supported alphabets', function (): void { + expect(fn(): string => BaseEncoder::encodeBytes('', 36))->toThrow(InvalidArgumentException::class) + ->and(fn(): string => BaseEncoder::encodeBytes(str_repeat("\0", 1025), 36))->toThrow(InvalidArgumentException::class) + ->and(fn(): string => BaseEncoder::encodeBytes("\x01", 37))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/FilesystemSequenceV5Test.php b/tests/FilesystemSequenceV5Test.php index b29a04c..e8dc94d 100644 --- a/tests/FilesystemSequenceV5Test.php +++ b/tests/FilesystemSequenceV5Test.php @@ -64,10 +64,9 @@ } }); -test('filesystem reservation ranges never overlap across processes', function () { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - $this->markTestSkipped('The pcntl extension is required for multi-process coverage'); - } +test('filesystem reservation ranges never overlap across processes', function (int $reservationSize) { + expect(function_exists('pcntl_fork'))->toBeTrue() + ->and(function_exists('pcntl_exec'))->toBeTrue(); $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6)); mkdir($directory, 0700); @@ -79,7 +78,7 @@ $pid = pcntl_fork(); expect($pid)->toBeGreaterThanOrEqual(0); if ($pid === 0) { - $provider = new FilesystemSequenceProvider($directory, 'shared', reservationSize: 16); + $provider = new FilesystemSequenceProvider($directory, 'shared', reservationSize: $reservationSize); $allocations = []; for ($index = 0; $index < 100; ++$index) { $allocations[] = $provider->next('sequence', 1, 123456); @@ -109,12 +108,11 @@ } rmdir($directory); } -}); +})->with([1, 16]); test('coordinated generators remain unique across processes', function (string $algorithm) { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - $this->markTestSkipped('The pcntl extension is required for multi-process coverage'); - } + expect(function_exists('pcntl_fork'))->toBeTrue() + ->and(function_exists('pcntl_exec'))->toBeTrue(); $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-v5-' . bin2hex(random_bytes(6)); mkdir($directory, 0700); diff --git a/tests/Fixtures/radix-vectors.json b/tests/Fixtures/radix-vectors.json new file mode 100644 index 0000000..1edbd40 --- /dev/null +++ b/tests/Fixtures/radix-vectors.json @@ -0,0 +1,632 @@ +[ + { + "hex": "00", + "base": 10, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00", + "base": 16, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00", + "base": 32, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00", + "base": 36, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00", + "base": 58, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "00", + "base": 62, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00000000000000", + "base": 10, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00000000000000", + "base": 16, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00000000000000", + "base": 32, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00000000000000", + "base": 36, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "00000000000000", + "base": 58, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "00000000000000", + "base": 62, + "sha256": "5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9" + }, + { + "hex": "01", + "base": 10, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "01", + "base": 16, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "01", + "base": 32, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "01", + "base": 36, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "01", + "base": 58, + "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35" + }, + { + "hex": "01", + "base": 62, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "010203", + "base": 10, + "sha256": "24260b3c030c6e4f86175247e322724d3997a1310105fa003b9edab91b80b000" + }, + { + "hex": "010203", + "base": 16, + "sha256": "91d29cb4702d2677c21abcbeefe7d75a9caff7761216ac71f8f6fb728a03cef7" + }, + { + "hex": "010203", + "base": 32, + "sha256": "e39b8e0726aa4a8b86b3096a3ca42923255db5f50dc193b0ce9f64c641cda133" + }, + { + "hex": "010203", + "base": 36, + "sha256": "db7116bec467527726501fc8a5b14ea6d94e304a0b6fdf4224e76ce0c7329b52" + }, + { + "hex": "010203", + "base": 58, + "sha256": "c666dfb52eb204a33e933a44afdcaab0b6d23c1675bb2d2d651c8e2e09a5c4c1" + }, + { + "hex": "010203", + "base": 62, + "sha256": "304bd1f15dba3af470b3de501f7a39c64551e431589a5403530d8622c6283209" + }, + { + "hex": "ffffffff", + "base": 10, + "sha256": "f807212b6748a8300fc3702322caa515edf0a6ed9bbc86e94c451e351b080c60" + }, + { + "hex": "ffffffff", + "base": 16, + "sha256": "a44ba123189855990795e3260a64b34cdae6b29bf1c941818a34cba8bbc45575" + }, + { + "hex": "ffffffff", + "base": 32, + "sha256": "edd5889f8f8ec599b6b440cf0073217786a62ddddcb06c540b71d1395a74f65b" + }, + { + "hex": "ffffffff", + "base": 36, + "sha256": "efce4cbc26a9225f27698923308bfcba97d727e33b42460817fd64eea9e63d96" + }, + { + "hex": "ffffffff", + "base": 58, + "sha256": "ed4eb404ddaf41bd8260aa633694def36054181d7205b0b25c30636ad7d33110" + }, + { + "hex": "ffffffff", + "base": 62, + "sha256": "e084d93abbe254544ab1ca05a8a3becd511915ec22c6fab371d500fa72b07811" + }, + { + "hex": "0100000000", + "base": 10, + "sha256": "6c1ca4002509bc24e93aa4211830738fce7e205e41364e11301ac5a0635cb4b5" + }, + { + "hex": "0100000000", + "base": 16, + "sha256": "e59bbea6227c578f97fc467bc62dc3407d4885693d74e6e970f6cab44158fef4" + }, + { + "hex": "0100000000", + "base": 32, + "sha256": "ace9f9389bff190c68b88f731056d0667d25438f8b5cb75b858b0ab6623fe08b" + }, + { + "hex": "0100000000", + "base": 36, + "sha256": "3e06c89d3a704cb155bdb96a0194e267c391d56656bb17927cff8c7151d21504" + }, + { + "hex": "0100000000", + "base": 58, + "sha256": "1d891934c5156b64b3ff9f4bbb75fb02a74948b9c87158d4b28925d2ac55817a" + }, + { + "hex": "0100000000", + "base": 62, + "sha256": "6689137fa3ce8c223bb53873a4a4ebd20db18558ee3f928703ffd0ee4c508ff9" + }, + { + "hex": "ffffffffffffff", + "base": 10, + "sha256": "2e979f5af74005579ea3ba3907f8de33d768f935115a03adc2cea6ba8cd62613" + }, + { + "hex": "ffffffffffffff", + "base": 16, + "sha256": "54861dbd9763f64887854ea22c2de89435f5b1917f8079ad719f8990563ae919" + }, + { + "hex": "ffffffffffffff", + "base": 32, + "sha256": "9727f344a8dbd01c4c06d0682b99921e77a917d8c86e9d102ac51ad272aeea90" + }, + { + "hex": "ffffffffffffff", + "base": 36, + "sha256": "32cd4e0080086ea66c93db83cb2db931a6f1aae0f8559042a0f52699961c88a6" + }, + { + "hex": "ffffffffffffff", + "base": 58, + "sha256": "c7c74041de89b891faa5d39dc26a7d61ce5a761faa16fedb8bebcefa5939a302" + }, + { + "hex": "ffffffffffffff", + "base": 62, + "sha256": "6c0e8b7c084ce2b6b115632e058e5ea1417d5d1d1ccfe0f97aca11ae5e7a9856" + }, + { + "hex": "ffffffffffffffff", + "base": 10, + "sha256": "2cdb26265b4dc65e3b44d694f121fd6de99b9e4b8ae7f08d84bfa9537635ae43" + }, + { + "hex": "ffffffffffffffff", + "base": 16, + "sha256": "6534b338bcb91cf173444c24ed8bc0f1b7065face0ea95cdd1b936556c6860ed" + }, + { + "hex": "ffffffffffffffff", + "base": 32, + "sha256": "186e9c46e8ef522bab0c99889bb79855051fd248a1f21c8d6613bf48e1a7cb4d" + }, + { + "hex": "ffffffffffffffff", + "base": 36, + "sha256": "208d1995d8a68c9d6fb54c610f05d3fdbc7c6d1fc2c762c28134512afcf31004" + }, + { + "hex": "ffffffffffffffff", + "base": 58, + "sha256": "8e4deb0ebdaa4a5316d3c6c3cd5aad65f3b23551a4e3e7bd8fd562fe6e758608" + }, + { + "hex": "ffffffffffffffff", + "base": 62, + "sha256": "751182822485df44ab62837e51046dae09dce275a529a350757c8eed4086284a" + }, + { + "hex": "010000000000000000", + "base": 10, + "sha256": "8b292fc2d32f1fd491784610c61b90dc4632a6e4add19261eca4c3979e4ea6d2" + }, + { + "hex": "010000000000000000", + "base": 16, + "sha256": "139eb393675707818651f879828a526159209ca3ad3b2f94f9f8ec8c4fb5e610" + }, + { + "hex": "010000000000000000", + "base": 32, + "sha256": "e251cf358c15b1e31a8102955a282c31a4a82b735076393aa79daad62d9eed04" + }, + { + "hex": "010000000000000000", + "base": 36, + "sha256": "8dd8e3c360e2ce9f175e735c81f62f3d04db3541a4ea3b4468aca16d0f72142a" + }, + { + "hex": "010000000000000000", + "base": 58, + "sha256": "9822cb8a94b821fb9a0c3559d5b8ae5970d5918d6203542412d8659fa0e47e66" + }, + { + "hex": "010000000000000000", + "base": 62, + "sha256": "6587c44d3f1cfec0fdfd9bf257ddbc17811d3e2ebea8469f4e3a37e609755e78" + }, + { + "hex": "000000000000000001", + "base": 10, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "000000000000000001", + "base": 16, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "000000000000000001", + "base": 32, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "000000000000000001", + "base": 36, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "000000000000000001", + "base": 58, + "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35" + }, + { + "hex": "000000000000000001", + "base": 62, + "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 10, + "sha256": "62f34becf1824be7bc2cd8bf3c74ce61a095a160a507818aef987bb29902972f" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 16, + "sha256": "79ad567e2df031ffca60b675bcbc52ef5d92e9acf36924aa85a0008860ff1219" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 32, + "sha256": "d996cf67728f15cdea403d1a95c72e5bf132b4f65f7603a8e5a0e27b427eff21" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 36, + "sha256": "9f2e8789ab4a1ecfaa3cbfd128cb41eb8f60f2cbac5afe39f5d91051f9e89597" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 58, + "sha256": "8817ec0e2858b0e01b19dbab30ff745b7effddb6411008260379ff24bd54c345" + }, + { + "hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "base": 62, + "sha256": "96dae209ef19bb45c0f0aa3e695323c0111d363a21499333ee04024de1b04fa5" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 10, + "sha256": "c7580a7344e36b87c5d8078a90b39aa93a2d6c5166083acddb12c1402a5038db" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 16, + "sha256": "d6be42c836477c05cd6d674c79c21cab7a381398b2e55f9bafaa1ad2573b564f" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 32, + "sha256": "949af726728ff4b2494bd9e4d7b0cd4a2dddeaefcc86298c6ea20dbcc5b3184a" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 36, + "sha256": "f8f98956a7547f3dcb96b7118e39f3ac54b47e3b453d6fb4c2546b32ddf079b6" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 58, + "sha256": "8a6b63d5ba101a9a5ed2a4a63478c215ab202763ab318af97d2a9e183be6b620" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "base": 62, + "sha256": "7e5371e1310f0fdcf424f3071fb88a731eb85e43401cb4e2a59804c27ae9ee99" + }, + { + "hex": "01869f", + "base": 10, + "sha256": "fd5f56b40a79a385708428e7b32ab996a681080a166a2206e750eb4819186145" + }, + { + "hex": "0186a0", + "base": 10, + "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1" + }, + { + "hex": "0186a1", + "base": 10, + "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841" + }, + { + "hex": "0f423f", + "base": 10, + "sha256": "937377f056160fc4b15e0b770c67136a5f03c15205b4d3bf918268fefa2c6d0a" + }, + { + "hex": "0f4240", + "base": 10, + "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45" + }, + { + "hex": "0f4241", + "base": 10, + "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0" + }, + { + "hex": "3b9ac9ff", + "base": 10, + "sha256": "bb421fa35db885ce507b0ef5c3f23cb09c62eb378fae3641c165bdf4c0272949" + }, + { + "hex": "3b9aca00", + "base": 10, + "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479" + }, + { + "hex": "3b9aca01", + "base": 10, + "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c" + }, + { + "hex": "0fffff", + "base": 16, + "sha256": "99834619b3c160248b69c7f42ba868f945a0ea04cd31cf2f60dc4bc8f7d13b8a" + }, + { + "hex": "100000", + "base": 16, + "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1" + }, + { + "hex": "100001", + "base": 16, + "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841" + }, + { + "hex": "ffffff", + "base": 16, + "sha256": "e2dbf8f5c4cc151480213d21f95c72aa73a001bce4915b17691ae40952dcd793" + }, + { + "hex": "01000000", + "base": 16, + "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45" + }, + { + "hex": "01000001", + "base": 16, + "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0" + }, + { + "hex": "0fffffffff", + "base": 16, + "sha256": "96c9766aa0d2590be49a4693e4919653a72537b662501f56dfa74404d06ccaef" + }, + { + "hex": "1000000000", + "base": 16, + "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479" + }, + { + "hex": "1000000001", + "base": 16, + "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c" + }, + { + "hex": "01ffffff", + "base": 32, + "sha256": "f3fb5428392674c123adc9798d8482876663ded63ef0b4d7a65adc290e6ced3f" + }, + { + "hex": "02000000", + "base": 32, + "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1" + }, + { + "hex": "02000001", + "base": 32, + "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841" + }, + { + "hex": "3fffffff", + "base": 32, + "sha256": "5e22363936e77b200d078a778a0eef2db904174476b41e4569df1344839ced8e" + }, + { + "hex": "40000000", + "base": 32, + "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45" + }, + { + "hex": "40000001", + "base": 32, + "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0" + }, + { + "hex": "1fffffffffff", + "base": 32, + "sha256": "76ed0b82ee51b4e3df728afe66507924922757eebd6b33764b4e6b86d34a2a1e" + }, + { + "hex": "200000000000", + "base": 32, + "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479" + }, + { + "hex": "200000000001", + "base": 32, + "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c" + }, + { + "hex": "039aa3ff", + "base": 36, + "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07" + }, + { + "hex": "039aa400", + "base": 36, + "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1" + }, + { + "hex": "039aa401", + "base": 36, + "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841" + }, + { + "hex": "81bf0fff", + "base": 36, + "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605" + }, + { + "hex": "81bf1000", + "base": 36, + "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45" + }, + { + "hex": "81bf1001", + "base": 36, + "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0" + }, + { + "hex": "5c5e4523ffff", + "base": 36, + "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033" + }, + { + "hex": "5c5e45240000", + "base": 36, + "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479" + }, + { + "hex": "5c5e45240001", + "base": 36, + "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c" + }, + { + "hex": "271f359f", + "base": 58, + "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07" + }, + { + "hex": "271f35a0", + "base": 58, + "sha256": "564374777639c82cf0c285c4b85704c0a19c0b8d008f25fc7ce4b560f031515c" + }, + { + "hex": "271f35a1", + "base": 58, + "sha256": "c8b5462497d37eb053ccd8954c5fa4c5932303f64687cfb30ec7279fcf3309a8" + }, + { + "hex": "08dd12263f", + "base": 58, + "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605" + }, + { + "hex": "08dd122640", + "base": 58, + "sha256": "5c4be4f3632c63145070274c378fede7b99e82b6dc80a7927bb58d5f0373b529" + }, + { + "hex": "08dd122641", + "base": 58, + "sha256": "66f4108f1134cabde7749f1a9be4aa62b317f07e2e5051116b307d9e7e9df37b" + }, + { + "hex": "1a636a90b079ff", + "base": 58, + "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033" + }, + { + "hex": "1a636a90b07a00", + "base": 58, + "sha256": "340076c5ad7c98d5f921b7fb87e03347ae1f012f89b3579f307ae827c52e2cf1" + }, + { + "hex": "1a636a90b07a01", + "base": 58, + "sha256": "f46dda0b91be8402b3228b9bb52de3e45bbdbf70e9e4c1ff2d2bf740874d7f27" + }, + { + "hex": "369b13df", + "base": 62, + "sha256": "68a55e5b1e43c67f4ef34065a86c4c583f532ae8e3cda7e36cc79b611802ac07" + }, + { + "hex": "369b13e0", + "base": 62, + "sha256": "3bb78535cc9555ff19fe3556aaa41c78a0a45c64d49ba2bc564507648a8e77a1" + }, + { + "hex": "369b13e1", + "base": 62, + "sha256": "97c489b6c1231ecd9fac99df40e60cec000a70a057d5971fb520c578da8e8841" + }, + { + "hex": "0d398ed03f", + "base": 62, + "sha256": "95fbeb8f769d2c0079d1d11348877da944aaefaba6ecf9f7f7dab6344ece8605" + }, + { + "hex": "0d398ed040", + "base": 62, + "sha256": "6cce36d9f8a9e151b100234af75cca89d55bcb94c153f51847debdf1f39cae45" + }, + { + "hex": "0d398ed041", + "base": 62, + "sha256": "443372db1ff98d9737596b4f83a8381ab6aab60e44e29854b48be88c74cdf1f0" + }, + { + "hex": "3017e892e23dff", + "base": 62, + "sha256": "f6dad0e59524cee41f6888a77281d94436f4c0615326875613cd55f521714033" + }, + { + "hex": "3017e892e23e00", + "base": 62, + "sha256": "52a5d4a071d82caea87329868d22f6b8390ac3d227c6fde0d4525e69510ec479" + }, + { + "hex": "3017e892e23e01", + "base": 62, + "sha256": "80a7cd0ffb78b364089257b612609ae24a9e886a2513ee0b3a7dc67b828c8c3c" + } +] diff --git a/tests/ForkSafetyTest.php b/tests/ForkSafetyTest.php index 0233587..d4b5f4f 100644 --- a/tests/ForkSafetyTest.php +++ b/tests/ForkSafetyTest.php @@ -9,9 +9,8 @@ use Infocyph\UID\XID; test('process-local generator state is reseeded after a fork', function (Closure $generator) { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - $this->markTestSkipped('The pcntl extension is required for fork-safety coverage'); - } + expect(function_exists('pcntl_fork'))->toBeTrue() + ->and(function_exists('pcntl_exec'))->toBeTrue(); $generator(); $resultFile = sys_get_temp_dir() . '/uid-fork-' . bin2hex(random_bytes(12)); diff --git a/tests/IdFactoryTest.php b/tests/IdFactoryTest.php index 4c816e5..7525d7b 100644 --- a/tests/IdFactoryTest.php +++ b/tests/IdFactoryTest.php @@ -6,6 +6,7 @@ use Infocyph\UID\Configuration\SonyflakeConfig; use Infocyph\UID\Configuration\TBSLConfig; use Infocyph\UID\Configuration\RandflakeConfig; +use Infocyph\UID\Enums\SonyflakeFormat; use Infocyph\UID\Enums\UlidGenerationMode; use Infocyph\UID\Id; use Infocyph\UID\RandomId; @@ -90,3 +91,14 @@ ->and($tbsl)->toHaveLength(20) ->and($randflake)->toBeString(); }); + + +test('Sonyflake value preserves explicit format metadata', function (): void { + $value = Id::sonyflakeValue(new SonyflakeConfig( + machineId: 42, + format: SonyflakeFormat::UPSTREAM, + )); + + expect($value->getFormat())->toBe(SonyflakeFormat::UPSTREAM) + ->and($value->getMachineId())->toBe(42); +}); diff --git a/tests/PersistentDomainLimitTest.php b/tests/PersistentDomainLimitTest.php new file mode 100644 index 0000000..b6db78a --- /dev/null +++ b/tests/PersistentDomainLimitTest.php @@ -0,0 +1,99 @@ + Snowflake::generateWithConfig(new SnowflakeConfig( + customEpoch: 1_700_000_002_000, + sequenceProvider: $provider, + runtime: $runtime, + )))->toThrow(SnowflakeException::class, 'domain limit exceeded'); +}); + +test('Sonyflake safety state fails closed instead of evicting live domains', function (): void { + $provider = statelessDomainProvider(); + $runtime = new GenerationContext(clock: new PersistentDomainClock()); + + for ($machineId = 0; $machineId < 1024; ++$machineId) { + Sonyflake::generateWithConfig(new SonyflakeConfig( + machineId: $machineId, + sequenceProvider: $provider, + runtime: $runtime, + )); + } + + expect(fn(): string => Sonyflake::generateWithConfig(new SonyflakeConfig( + machineId: 1024, + sequenceProvider: $provider, + runtime: $runtime, + )))->toThrow(SonyflakeException::class, 'domain limit exceeded'); +}); + +test('Randflake safety state fails closed instead of evicting live domains', function (): void { + $provider = statelessDomainProvider(); + $runtime = new GenerationContext(clock: new PersistentDomainClock()); + $secret = '0123456789abcdef'; + + for ($nodeId = 0; $nodeId < 1024; ++$nodeId) { + Randflake::generateWithConfig(new RandflakeConfig( + nodeId: $nodeId, + leaseStart: 1_799_999_999, + leaseEnd: 1_800_000_001, + secret: $secret, + sequenceProvider: $provider, + runtime: $runtime, + )); + } + + expect(fn(): string => Randflake::generateWithConfig(new RandflakeConfig( + nodeId: 1024, + leaseStart: 1_799_999_999, + leaseEnd: 1_800_000_001, + secret: $secret, + sequenceProvider: $provider, + runtime: $runtime, + )))->toThrow(RandflakeException::class, 'domain limit exceeded'); +}); diff --git a/tests/RandflakeUpstreamFormatTest.php b/tests/RandflakeUpstreamFormatTest.php new file mode 100644 index 0000000..029c038 --- /dev/null +++ b/tests/RandflakeUpstreamFormatTest.php @@ -0,0 +1,64 @@ +toBe('2111581968557607991') + ->and(Randflake::encodeString($id, RandflakeFormat::UPSTREAM))->toBe('1qjeojjevu31n') + ->and(Randflake::decodeString('1qjeojjevu31n', RandflakeFormat::UPSTREAM))->toBe($id) + ->and(Randflake::inspect($id, $secret, RandflakeFormat::UPSTREAM))->toBe([ + 'timestamp' => 1_730_000_001, + 'node_id' => 0, + 'sequence' => 0, + ]); +}); + +test('Randflake upstream codec preserves signed vectors', function (): void { + expect(Randflake::decodeString('fphhelk04q8f8', RandflakeFormat::UPSTREAM)) + ->toBe('-232447010193727000') + ->and(Randflake::encodeString('-232447010193727000', RandflakeFormat::UPSTREAM)) + ->toBe('fphhelk04q8f8'); +}); + +test('Randflake legacy format remains the default', function (): void { + $secret = '0123456789abcdef'; + $id = Randflake::generate( + nodeId: 7, + leaseStart: time() - 1, + leaseEnd: time() + 5, + secret: $secret, + ); + + expect(Randflake::isValid($id))->toBeTrue() + ->and(Randflake::encodeString(Randflake::decodeString(Randflake::encodeString($id))))->toBe( + Randflake::encodeString($id), + ); +}); diff --git a/tests/RandflakeUpstreamPrimitiveTest.php b/tests/RandflakeUpstreamPrimitiveTest.php new file mode 100644 index 0000000..15bfeb0 --- /dev/null +++ b/tests/RandflakeUpstreamPrimitiveTest.php @@ -0,0 +1,28 @@ +toBe($value); + } +}); + +test('SPARX64 encryption round trips fixed blocks', function (): void { + $cipher = new Sparx64(hex2bin('000102030405060708090a0b0c0d0e0f')); + $plain = hex2bin('0011223344556677'); + $encrypted = $cipher->encrypt($plain); + + expect($encrypted)->not()->toBe($plain) + ->and($cipher->decrypt($encrypted))->toBe($plain); +}); diff --git a/tests/ReleaseBoundaryTest.php b/tests/ReleaseBoundaryTest.php new file mode 100644 index 0000000..868ff76 --- /dev/null +++ b/tests/ReleaseBoundaryTest.php @@ -0,0 +1,182 @@ +time; + } +} + +test('bound filesystem providers reject cancellation before fresh and reserved allocation', function (int $reservationSize): void { + $directory = sys_get_temp_dir() . '/uid-boundary-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $host = RuntimeContext::standalone(); + $request = RequestContext::create($host); + $provider = new FilesystemSequenceProvider( + $directory, + reservationSize: $reservationSize, + runtime: new GenerationContext(runwire: new RunwireBinding($host, $request)), + ); + $path = $directory . '/uid-test-1.seq'; + + try { + expect($provider->next('test', 1, 100))->toBe(1); + $before = file_get_contents($path); + $request->cancel(CancellationReason::HOST_CANCELLED); + expect(fn(): int => $provider->next('test', 1, 100))->toThrow(CancelledException::class) + ->and(file_get_contents($path))->toBe($before); + } finally { + unlink($path); + rmdir($directory); + } +})->with([1, 8]); + +test('TBSL clocks remain independent across provider and machine domains', function (): void { + $lateClock = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1800000000'))); + $earlyClock = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000'))); + $provider = new InMemorySequenceProvider(); + TBSL::generateWithConfig(new TBSLConfig(sequenceProvider: $provider, runtime: $lateClock)); + + foreach ([ + new TBSLConfig(sequenceProvider: new InMemorySequenceProvider(), runtime: $earlyClock, clockBackwardPolicy: ClockBackwardPolicy::THROW), + new TBSLConfig(machineId: 1, sequenceProvider: $provider, runtime: $earlyClock, clockBackwardPolicy: ClockBackwardPolicy::THROW), + ] as $config) { + $id = TBSL::generateWithConfig($config); + expect(TBSL::parse($id)['time']->getTimestamp())->toBe(1_700_000_000); + } + + expect(fn(): string => TBSL::generateWithConfig(new TBSLConfig( + sequenceProvider: $provider, + runtime: $earlyClock, + clockBackwardPolicy: ClockBackwardPolicy::THROW, + )))->toThrow(UIDException::class, 'Clock moved backwards'); +}); + +test('Sonyflake rejects future epochs inside one clock tick', function (SonyflakeFormat $format, int $offset): void { + $provider = new InMemorySequenceProvider(); + $config = new SonyflakeConfig( + customEpoch: 1_700_000_000_000 + $offset, + sequenceProvider: $provider, + runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000'))), + format: $format, + ); + + expect(fn(): string => Sonyflake::generateWithConfig($config)) + ->toThrow(SonyflakeException::class, 'epoch must not be in the future'); +})->with([SonyflakeFormat::UID, SonyflakeFormat::UPSTREAM])->with([1, 9]); + +test('TBSL preserves eleven-digit seconds within its 60-bit timestamp field', function (): void { + $runtime = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@10000000000.123456'))); + $id = TBSL::generateWithConfig(new TBSLConfig(machineId: 99, sequenceProvider: new InMemorySequenceProvider(), runtime: $runtime)); + $parsed = TBSL::parse($id); + expect($parsed['time']->format('U.u'))->toBe('10000000000.123456') + ->and($parsed['machineId'])->toBe(99); +}); + +test('generation rejects out-of-domain clocks and keeps large wait budgets integer safe', function (): void { + $negative = new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('1969-12-31T23:59:59.500000Z'))); + expect(fn(): int => $negative->nowMicroseconds())->toThrow(InvalidArgumentException::class) + ->and((new GenerationContext(waitTimeoutMicros: PHP_INT_MAX))->waitDeadlineNanoseconds())->toBeInt(); +}); + +test('upstream Randflake lease conversion fails with a domain error at integer exhaustion', function (): void { + $config = new \Infocyph\UID\Configuration\RandflakeConfig( + 0, 1_730_000_000, PHP_INT_MAX, '0123456789abcdef', + format: \Infocyph\UID\Enums\RandflakeFormat::UPSTREAM, + ); + expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config)) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class, 'exclusive boundary'); +}); + +test('Randflake rechecks live domain state after reentrant provider work', function (\Infocyph\UID\Enums\RandflakeFormat $format): void { + $nested = false; + $config = null; + $nestedId = null; + $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider( + function (string $type, int $machineId, int $timestamp) use (&$nested, &$config, &$nestedId): int { + unset($type, $machineId, $timestamp); + if (!$nested) { + $nested = true; + $nestedId = \Infocyph\UID\Randflake::generateWithConfig($config); + } + + return 1; + }, + ); + $config = new \Infocyph\UID\Configuration\RandflakeConfig( + 0, 1_730_000_000, 1_730_000_005, '0123456789abcdef', + sequenceProvider: $provider, + runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1730000001'))), + format: $format, + ); + + expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config)) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class, 'allocation regressed') + ->and($nestedId)->toBeString(); +})->with([\Infocyph\UID\Enums\RandflakeFormat::UID, \Infocyph\UID\Enums\RandflakeFormat::UPSTREAM]); + +test('UUID node arguments reject trailing line breaks', function (): void { + foreach (['v1', 'v6', 'v8'] as $method) { + expect(fn(): string => \Infocyph\UID\UUID::$method("0123456789ab\n")) + ->toThrow(\Infocyph\UID\Exceptions\UUIDException::class); + } +}); + +test('implicit ULID and UUID generation fail promptly at terminal timestamp exhaustion', function (): void { + foreach ([ + [\Infocyph\UID\ULID::class, 'waitForNextMillisecond', \Infocyph\UID\Exceptions\ULIDException::class], + [\Infocyph\UID\UUID::class, 'nextV7Timestamp', \Infocyph\UID\Exceptions\UUIDException::class], + ] as [$class, $method, $exception]) { + $wait = new ReflectionMethod($class, $method); + expect(fn(): int => $wait->invoke(null, 281_474_976_710_655))->toThrow($exception, 'exhausted'); + } +}); + +test('Randflake rejects injected times before its epoch without consuming an allocation', function (): void { + $calls = 0; + $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider(function (string $type, int $machineId, int $timestamp) use (&$calls): int { + unset($type, $machineId, $timestamp); + return ++$calls; + }); + $config = new \Infocyph\UID\Configuration\RandflakeConfig( + 0, 0, 1_730_000_005, '0123456789abcdef', + sequenceProvider: $provider, + runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1729999999'))), + ); + expect(fn(): string => \Infocyph\UID\Randflake::generateWithConfig($config)) + ->toThrow(\Infocyph\UID\Exceptions\RandflakeException::class) + ->and($calls)->toBe(0); +}); + +test('Sonyflake rejects extreme epochs with a domain error before integer division', function (): void { + $config = new SonyflakeConfig( + customEpoch: PHP_INT_MIN, + sequenceProvider: new InMemorySequenceProvider(), + runtime: new GenerationContext(clock: new ReleaseBoundaryClock(new DateTimeImmutable('@1700000000'))), + ); + expect(fn(): string => Sonyflake::generateWithConfig($config)) + ->toThrow(SonyflakeException::class, 'maximum life cycle'); +}); diff --git a/tests/RuntimeIntegrationTest.php b/tests/RuntimeIntegrationTest.php new file mode 100644 index 0000000..7cb92e4 --- /dev/null +++ b/tests/RuntimeIntegrationTest.php @@ -0,0 +1,299 @@ +time; + } +} + +final class OverflowUidSequenceProvider implements SequenceProviderInterface +{ + public function next(string $type, int $machineId, int $timestamp): int + { + unset($type, $machineId, $timestamp); + + return 4_097; + } +} + +test('frozen injected clocks exhaust the bounded generation wait', function (): void { + $runtime = new GenerationContext( + clock: new FrozenUidClock(new DateTimeImmutable('2026-10-06T15:00:00.000000+00:00')), + waitTimeoutMicros: 2_000, + ); + $config = new SnowflakeConfig( + sequenceProvider: new OverflowUidSequenceProvider(), + runtime: $runtime, + ); + + expect(fn(): string => Snowflake::generateWithConfig($config)) + ->toThrow(SnowflakeException::class, 'Timed out waiting for a valid Snowflake timestamp'); +}); + +test('Runwire cancellation stops generation before allocation', function (): void { + $host = RuntimeContext::standalone(); + $request = RequestContext::create($host); + $binding = new RunwireBinding($host, $request); + $runtime = new GenerationContext(runwire: $binding); + $provider = new InMemorySequenceProvider(); + + $request->cancel(CancellationReason::HOST_CANCELLED); + + expect(fn(): string => Snowflake::generateWithConfig(new SnowflakeConfig( + sequenceProvider: $provider, + runtime: $runtime, + )))->toThrow(CancelledException::class) + ->and($provider->next('probe', 0, 1))->toBe(1); +}); + +test('native generation remains available without optional runtime binding', function (): void { + expect(Snowflake::isValid(Snowflake::generateWithConfig(new SnowflakeConfig( + sequenceProvider: new InMemorySequenceProvider(), + ))))->toBeTrue(); +}); + + +test('Runwire binding rejects a request from another runtime', function (): void { + $left = RuntimeContext::standalone(); + $right = RuntimeContext::standalone(); + $request = RequestContext::create($left); + + expect(fn(): RunwireBinding => new RunwireBinding($right, $request)) + ->toThrow(LogicException::class, 'different runtime context'); +}); + +test('Runwire binding rejects completed requests', function (): void { + $host = RuntimeContext::standalone(); + $request = RequestContext::create($host); + $request->complete(); + + expect(fn(): RunwireBinding => new RunwireBinding($host, $request)) + ->toThrow(LogicException::class, 'already completed'); +}); + +test('Runwire binding preserves exact host instances and cooperative scope waits', function (): void { + $capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ); + $host = RuntimeContext::fromCapabilities($capabilities, 'uid-test', concurrent: true); + $request = RequestContext::create($host); + $coroutines = new CoroutineRuntime(); + + $result = $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($host, $request): array { + $binding = new RunwireBinding($host, $request, $scope); + $runtime = new GenerationContext(runwire: $binding, waitTimeoutMicros: 10_000); + $runtime->sleepMicroseconds(100); + + return [ + $binding->runtime === $host, + $binding->request === $request, + $binding->scope === $scope, + ]; + }, + ); + + expect($result)->toBe([true, true, true]) + ->and($request->completed())->toBeFalse(); +}); + +test('a cancellation after allocation never recycles the consumed allocation', function (): void { + $host = RuntimeContext::standalone(); + $request = RequestContext::create($host); + $runtime = new GenerationContext(runwire: new RunwireBinding($host, $request)); + $allocations = 0; + $provider = new \Infocyph\UID\Sequence\CallbackSequenceProvider( + static function (string $type, int $machineId, int $timestamp) use (&$allocations, $request): int { + unset($type, $machineId, $timestamp); + ++$allocations; + $request->cancel(CancellationReason::HOST_CANCELLED); + + return $allocations; + }, + ); + + Snowflake::generateWithConfig(new SnowflakeConfig( + sequenceProvider: $provider, + runtime: $runtime, + )); + + expect($allocations)->toBe(1) + ->and(fn(): string => Snowflake::generateWithConfig(new SnowflakeConfig( + sequenceProvider: $provider, + runtime: $runtime, + )))->toThrow(CancelledException::class) + ->and($allocations)->toBe(1); +}); + + +function forwardUidSnowflake(SnowflakeConfig $config): string +{ + return Snowflake::generateWithConfig($config); +} + +test('Runwire bindings survive intermediary config forwarding without rediscovery', function (): void { + $host = RuntimeContext::standalone(); + $request = RequestContext::create($host); + $binding = new RunwireBinding($host, $request); + $config = new SnowflakeConfig( + sequenceProvider: new InMemorySequenceProvider(), + runtime: new GenerationContext(runwire: $binding), + ); + + expect(forwardUidSnowflake($config))->toBeString() + ->and($config->runtime?->runwire)->toBe($binding) + ->and($binding->runtime)->toBe($host) + ->and($binding->request)->toBe($request); +}); + +test('Runwire bindings reject scopes after the host closes them', function (): void { + $capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ); + $host = RuntimeContext::fromCapabilities($capabilities, 'uid-closed-scope', concurrent: true); + $request = RequestContext::create($host); + $coroutines = new CoroutineRuntime(); + $capturedScope = null; + + $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use (&$capturedScope): void { + $capturedScope = $scope; + }, + ); + + expect($capturedScope)->toBeInstanceOf(CoroutineScope::class) + ->and(fn(): RunwireBinding => new RunwireBinding($host, $request, $capturedScope)) + ->toThrow(LogicException::class, 'already closed'); +}); + +test('contended locks suspend cooperatively and stop on host cancellation', function (bool $cancel): void { + $directory = sys_get_temp_dir() . '/uid-cooperative-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $path = $directory . '/uid-snowflake-0.seq'; + file_put_contents($path, '1700000000000,7'); + $held = fopen($path, 'r+b'); + expect(flock($held, LOCK_EX))->toBeTrue(); + $host = RuntimeContext::fromCapabilities(new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ), 'uid-contended', concurrent: true); + $request = RequestContext::create($host); + $coroutines = new CoroutineRuntime(); + $ranOtherTask = false; + + $allocate = function () use ($coroutines, $request, $host, $held, $directory, $cancel, &$ranOtherTask): string { + return $coroutines->runRequest($request, function (CoroutineScope $scope) use ($request, $host, $held, $directory, $cancel, &$ranOtherTask): string { + $scope->spawn(function () use ($scope, $request, $held, $cancel, &$ranOtherTask): void { + $scope->sleep(0.005); + $ranOtherTask = true; + if ($cancel) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + flock($held, LOCK_UN); + }); + // A worker-owned provider receives each request's binding through the config. + $provider = new \Infocyph\UID\Sequence\FilesystemSequenceProvider($directory); + + return forwardUidSnowflake(new SnowflakeConfig( + sequenceProvider: $provider, + runtime: new GenerationContext( + clock: new FrozenUidClock(new DateTimeImmutable('@1700000000')), + runwire: new RunwireBinding($host, $request, $scope), + waitTimeoutMicros: 100_000, + ), + )); + }); + }; + + try { + if ($cancel) { + expect($allocate)->toThrow(CancelledException::class) + ->and(file_get_contents($path))->toBe('1700000000000,7'); + } else { + expect(Snowflake::parse($allocate())['sequence'])->toBe(7) + ->and(file_get_contents($path))->toBe('1700000000000,8') + ->and($request->completed())->toBeFalse(); + } + expect($ranOtherTask)->toBeTrue(); + } finally { + flock($held, LOCK_UN); + fclose($held); + unlink($path); + rmdir($directory); + } +})->with([false, true]); + +test('lock timeout forbids allocation after a late cooperative wake', function (): void { + $directory = sys_get_temp_dir() . '/uid-late-wake-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $path = $directory . '/uid-test-1.seq'; + file_put_contents($path, '100,1'); + $held = fopen($path, 'r+b'); + expect(flock($held, LOCK_EX))->toBeTrue(); + $host = RuntimeContext::fromCapabilities(new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ), 'uid-late-wake', concurrent: true); + $request = RequestContext::create($host); + $coroutines = new CoroutineRuntime(); + + try { + $failure = $coroutines->runRequest($request, function (CoroutineScope $scope) use ($host, $request, $held, $directory): ?\Infocyph\UID\Exceptions\FileLockException { + $scope->spawn(function () use ($held): void { + // Simulate host work delaying the allocator's scheduled wake. + usleep(10_000); + flock($held, LOCK_UN); + }); + $provider = new \Infocyph\UID\Sequence\FilesystemSequenceProvider($directory); + try { + $provider->next('test', 1, 100, new GenerationContext( + runwire: new RunwireBinding($host, $request, $scope), + waitTimeoutMicros: 5_000, + )); + } catch (\Infocyph\UID\Exceptions\FileLockException $exception) { + return $exception; + } + + return null; + }); + expect($failure)->toBeInstanceOf(\Infocyph\UID\Exceptions\FileLockException::class) + ->and(file_get_contents($path))->toBe('100,1') + ->and($request->completed())->toBeFalse(); + } finally { + flock($held, LOCK_UN); + fclose($held); + unlink($path); + rmdir($directory); + } +}); diff --git a/tests/SequenceDomainLimitTest.php b/tests/SequenceDomainLimitTest.php new file mode 100644 index 0000000..b9f5994 --- /dev/null +++ b/tests/SequenceDomainLimitTest.php @@ -0,0 +1,91 @@ + */ + private array $values = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool + { + unset($ttl); + $this->values[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete((string) $key); + } + + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->values); + } +} + +test('in-memory sequence state fails closed at the live-domain bound', function (): void { + $provider = new InMemorySequenceProvider(); + for ($domain = 0; $domain < 1024; ++$domain) { + expect($provider->next('domain-' . $domain, 0, 1))->toBe(1); + } + + expect(fn(): int => $provider->next('domain-overflow', 0, 1)) + ->toThrow(FileLockException::class, 'domain limit exceeded'); +}); + +test('PSR-16 observed safety state fails closed at the live-domain bound', function (): void { + $provider = new PsrSimpleCacheSequenceProvider(new DomainLimitCache()); + for ($domain = 0; $domain < 1024; ++$domain) { + expect($provider->next('d' . $domain, 0, 1))->toBe(1); + } + + expect(fn(): int => $provider->next('overflow', 0, 1)) + ->toThrow(FileLockException::class, 'domain limit exceeded'); +}); diff --git a/tests/SequenceProviderTest.php b/tests/SequenceProviderTest.php index 5a03d6c..d286dff 100644 --- a/tests/SequenceProviderTest.php +++ b/tests/SequenceProviderTest.php @@ -11,6 +11,8 @@ final class SequenceTestCache implements CacheInterface { + public ?Closure $beforeRead = null; + public bool $failWrites = false; public null|int|DateInterval $lastTtl = null; @@ -43,6 +45,8 @@ public function deleteMultiple(iterable $keys): bool public function get(string $key, mixed $default = null): mixed { + ($this->beforeRead ?? static function (): void {})(); + return $this->store[$key] ?? $default; } @@ -90,6 +94,36 @@ public function setMultiple(iterable $values, null|int|DateInterval $ttl = null) } } +test('bound cache providers reject cancellation before writes and inside synchronizers', function (string $phase): void { + $host = \Infocyph\Runwire\RuntimeContext::standalone(); + $request = \Infocyph\Runwire\RequestContext::create($host); + $runtime = new \Infocyph\UID\Runtime\GenerationContext( + runwire: new \Infocyph\UID\Runtime\RunwireBinding($host, $request), + ); + $cache = new SequenceTestCache(); + $cancel = static fn() => $request->cancel(\Infocyph\Runwire\Runtime\Enum\CancellationReason::HOST_CANCELLED); + $synchronizer = static function (string $key, callable $allocate) use ($cancel, $phase): int { + unset($key); + if ($phase === 'synchronizer') { + $cancel(); + } + + return $allocate(); + }; + $provider = new PsrSimpleCacheSequenceProvider($cache, synchronizer: $synchronizer, runtime: $runtime); + + if ($phase === 'entry') { + $cancel(); + } elseif ($phase === 'read') { + $cache->beforeRead = $cancel; + } + + expect(fn(): int => $provider->next('test', 1, 100)) + ->toThrow(\Infocyph\Runwire\Exception\CancelledException::class); + $cache->beforeRead = null; + expect($cache->has('uid.seq.test.1'))->toBeFalse(); +})->with(['entry', 'read', 'synchronizer']); + final class FutureOnceSequenceProvider implements SequenceProviderInterface { public int $calls = 0; @@ -224,3 +258,27 @@ public function next(string $type, int $machineId, int $timestamp): int } } }); + + +test('psr-16 sequence provider fails closed after observed state loss', function () { + $cache = new SequenceTestCache(); + $provider = new PsrSimpleCacheSequenceProvider($cache); + expect($provider->next('snowflake', 1, 100))->toBe(1); + $cache->clear(); + + expect(fn(): int => $provider->next('snowflake', 1, 100)) + ->toThrow(\Infocyph\UID\Exceptions\FileLockException::class); +}); + +test('custom epochs are normalized at configuration construction', function () { + $epoch = new DateTime('2024-01-01T00:00:00+00:00'); + $snowflake = new \Infocyph\UID\Configuration\SnowflakeConfig(customEpoch: $epoch); + $sonyflake = new \Infocyph\UID\Configuration\SonyflakeConfig(customEpoch: $epoch); + $snowflakeEpoch = $snowflake->resolveCustomEpochMs(); + $sonyflakeEpoch = $sonyflake->resolveCustomEpochMs(); + + $epoch->modify('+1 day'); + + expect($snowflake->resolveCustomEpochMs())->toBe($snowflakeEpoch) + ->and($sonyflake->resolveCustomEpochMs())->toBe($sonyflakeEpoch); +}); diff --git a/tests/SequenceSafetyTest.php b/tests/SequenceSafetyTest.php new file mode 100644 index 0000000..9d09376 --- /dev/null +++ b/tests/SequenceSafetyTest.php @@ -0,0 +1,179 @@ +toBeTrue(); + $directory = sys_get_temp_dir() . '/uid-lock-race-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $path = $directory . '/state'; + $target = $directory . '/target'; + file_put_contents($path, '100,1'); + file_put_contents($target, 'unchanged'); + $handle = fopen($path, 'r+b'); + $before = null; + $verify = Closure::bind( + static function () use ($path, $handle, &$before) { + return FileLock::verifyHandle($path, $handle, $before, 'replaced lock', function_exists('posix_geteuid') ? posix_geteuid() : null); + }, + null, + FileLock::class, + ); + expect($verify)->toBeInstanceOf(Closure::class); + // Prime PHP's path cache after loading the verifier and assertion machinery. + $before = lstat($path); + $pid = pcntl_fork(); + if ($pid < 0) { + throw new RuntimeException('Unable to fork pathname replacement fixture'); + } + if ($pid === 0) { + rename($path, $path . '.original'); + symlink($target, $path); + exit(0); + } + pcntl_waitpid($pid, $status); + + try { + $failure = null; + try { + $verify(); + } catch (Throwable $exception) { + $failure = $exception; + } + expect($failure)->toBeInstanceOf(FileLockException::class) + ->and(file_get_contents($target))->toBe('unchanged'); + } finally { + if (is_resource($handle)) { + fclose($handle); + } + unlink($path); + unlink($path . '.original'); + unlink($target); + rmdir($directory); + } +}); + +test('filesystem sequence rejects symlink state without touching its target', function (): void { + if (PHP_OS_FAMILY === 'Windows') { + expect(true)->toBeTrue(); + + return; + } + + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-safety-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $target = $directory . DIRECTORY_SEPARATOR . 'target'; + $link = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq'; + file_put_contents($target, 'unchanged'); + symlink($target, $link); + + try { + $provider = new FilesystemSequenceProvider($directory); + expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class) + ->and(file_get_contents($target))->toBe('unchanged'); + } finally { + if (is_link($link)) { + unlink($link); + } + if (is_file($target)) { + unlink($target); + } + if (is_dir($directory)) { + rmdir($directory); + } + } +}); + +test('cached parent resolution cannot allocate through an externally replaced directory link', function (): void { + expect(function_exists('pcntl_fork'))->toBeTrue(); + $directory = sys_get_temp_dir() . '/uid-parent-race-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + mkdir($directory . '/first', 0700); + mkdir($directory . '/second', 0700); + $first = $directory . '/first/uid-test-1.seq'; + $second = $directory . '/second/uid-test-1.seq'; + $alias = $directory . '/current'; + $path = $alias . '/uid-test-1.seq'; + file_put_contents($first, '100,1'); + file_put_contents($second, '100,100'); + symlink($directory . '/first', $alias); + $provider = new FilesystemSequenceProvider($alias); + expect(realpath($path))->toBe($first); + lstat($path); + + $pid = pcntl_fork(); + if ($pid < 0) { + throw new RuntimeException('Unable to fork directory replacement fixture'); + } + if ($pid === 0) { + unlink($alias); + symlink($directory . '/second', $alias); + exit(0); + } + pcntl_waitpid($pid, $status); + + try { + try { + expect($provider->next('test', 1, 100))->toBe(101); + } catch (FileLockException) { + expect(file_get_contents($second))->toBe('100,100'); + } + expect(file_get_contents($first))->toBe('100,1'); + } finally { + unlink($alias); + unlink($first); + unlink($second); + rmdir($directory . '/first'); + rmdir($directory . '/second'); + rmdir($directory); + } +}); + +test('filesystem sequence fails closed at integer exhaustion', function (): void { + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-safety-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $state = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq'; + file_put_contents($state, '100,' . PHP_INT_MAX); + + try { + $provider = new FilesystemSequenceProvider($directory); + expect(fn(): int => $provider->next('test', 1, 100))->toThrow(FileLockException::class) + ->and(file_get_contents($state))->toBe('100,' . PHP_INT_MAX); + } finally { + if (is_file($state)) { + unlink($state); + } + if (is_dir($directory)) { + rmdir($directory); + } + } +}); + +test('filesystem sequence rejects noncanonical and overflowing state without rewriting it', function (string $contents): void { + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'uid-state-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700); + $state = $directory . DIRECTORY_SEPARATOR . 'uid-test-1.seq'; + file_put_contents($state, $contents); + + try { + $provider = new FilesystemSequenceProvider($directory); + expect(fn(): int => $provider->next('test', 1, PHP_INT_MAX))->toThrow(FileLockException::class) + ->and(file_get_contents($state))->toBe($contents); + } finally { + unlink($state); + rmdir($directory); + } +})->with([ + 'timestamp overflow' => '9223372036854775808,0', + 'allocation overflow' => '0,9223372036854775808', + 'timestamp leading zero' => '01,1', + 'allocation leading zero' => '1,01', + 'signed allocation' => '1,+1', + 'trailing newline' => "1,1\n", + 'extra field' => '1,1,1', + 'empty field' => '1,', +]); diff --git a/tests/SonyflakeFormatTest.php b/tests/SonyflakeFormatTest.php new file mode 100644 index 0000000..ffc8175 --- /dev/null +++ b/tests/SonyflakeFormatTest.php @@ -0,0 +1,26 @@ +toBe('16842794'); + + $parts = Sonyflake::parseWithEpoch($id, $epoch, SonyflakeFormat::UPSTREAM); + expect($parts['sequence'])->toBe(1) + ->and($parts['machine_id'])->toBe(42) + ->and($parts['time']->format('Uv'))->toBe((string) ($epoch + 10)); +}); + +test('Sonyflake legacy mode keeps UID field ordering', function (): void { + $epoch = 1_577_836_800_000; + $id = (string) ((1 << 24) | (42 << 8) | 1); + $parts = Sonyflake::parseWithEpoch($id, $epoch); + + expect($parts['sequence'])->toBe(1) + ->and($parts['machine_id'])->toBe(42); +}); diff --git a/tests/Uid6RegressionTest.php b/tests/Uid6RegressionTest.php new file mode 100644 index 0000000..05bfd0c --- /dev/null +++ b/tests/Uid6RegressionTest.php @@ -0,0 +1,47 @@ +toBeFalse() + ->and(NanoID::isValid("abc\n"))->toBeFalse() + ->and(TBSL::isValid(str_repeat('0', 20) . "\n"))->toBeFalse(); +}); + +test('mixed comparator order is transitive', function (): void { + expect(IdComparator::sort(['1a', '10', '2']))->toBe(['2', '10', '1a']) + ->and(IdComparator::compare('2', '10'))->toBeLessThan(0) + ->and(IdComparator::compare('10', '1a'))->toBeLessThan(0) + ->and(IdComparator::compare('2', '1a'))->toBeLessThan(0); +}); + +test('guid fallback braces normalize cleanly', function (): void { + $guid = UUID::guid(false); + expect($guid)->toMatch('/^\{[0-9a-f-]{36}\}$/i') + ->and(UUID::isValid(trim($guid, '{}')))->toBeTrue() + ->and(UUID::fromBytes(UUID::toBytes($guid)))->toBe(strtolower(trim($guid, '{}'))); +}); + +test('signed numeric decoders reject the unsigned 64-bit maximum', function (): void { + $bytes = str_repeat("\xff", 8); + expect(fn(): string => Snowflake::fromBytes($bytes)) + ->toThrow(\Infocyph\UID\Exceptions\SnowflakeException::class) + ->and(fn(): string => Sonyflake::fromBytes($bytes)) + ->toThrow(\Infocyph\UID\Exceptions\SonyflakeException::class); +}); + +test('opaque ids reject decoded values outside the generation domain', function (): void { + $token = BaseEncoder::encodeBytes(str_repeat("\xff", 8), 62); + expect(fn(): int => OpaqueId::toInt($token)) + ->toThrow(\InvalidArgumentException::class); +}); diff --git a/tests/V5CompatibilityTest.php b/tests/V5CompatibilityTest.php index bbed3c2..80c354c 100644 --- a/tests/V5CompatibilityTest.php +++ b/tests/V5CompatibilityTest.php @@ -125,3 +125,119 @@ test('the installed runtime satisfies the 64-bit package contract', function () { expect(PHP_INT_SIZE)->toBe(8); }); + + +test('v5 public helper and facade parameter names remain stable', function (): void { + $functions = [ + 'Infocyph\\UID\\cuid2' => ['length'], + 'Infocyph\\UID\\ksuid' => ['dateTime'], + 'Infocyph\\UID\\nano_id' => ['length'], + 'Infocyph\\UID\\object_id' => ['dateTime'], + 'Infocyph\\UID\\randflake' => ['config'], + 'Infocyph\\UID\\random_id' => ['length', 'alphabet'], + 'Infocyph\\UID\\snowflake' => ['config'], + 'Infocyph\\UID\\sonyflake' => ['config'], + 'Infocyph\\UID\\tbsl' => ['config'], + 'Infocyph\\UID\\type_id' => ['type'], + 'Infocyph\\UID\\ulid' => ['dateTime', 'mode'], + 'Infocyph\\UID\\uuid1' => ['node'], + 'Infocyph\\UID\\uuid3' => ['namespace', 'string'], + 'Infocyph\\UID\\uuid4' => [], + 'Infocyph\\UID\\uuid5' => ['namespace', 'string'], + 'Infocyph\\UID\\uuid6' => ['node'], + 'Infocyph\\UID\\uuid7' => ['dateTime'], + 'Infocyph\\UID\\uuid8' => ['node'], + 'Infocyph\\UID\\xid' => [], + ]; + + foreach ($functions as $function => $expected) { + $actual = array_map( + static fn(ReflectionParameter $parameter): string => $parameter->getName(), + (new ReflectionFunction($function))->getParameters(), + ); + expect($actual)->toBe($expected); + } + + $methods = [ + 'cuid2' => ['length'], + 'deterministic' => ['payload', 'length', 'namespace'], + 'ksuid' => ['dateTime'], + 'nanoId' => ['length'], + 'objectId' => ['dateTime'], + 'randflake' => ['config'], + 'random' => ['length', 'alphabet'], + 'snowflake' => ['config'], + 'snowflakeValue' => ['config'], + 'sonyflake' => ['config'], + 'sonyflakeValue' => ['config'], + 'tbsl' => ['config'], + 'typeId' => ['type'], + 'ulid' => ['dateTime', 'mode'], + 'uuid' => ['dateTime'], + 'uuid1' => ['node'], + 'uuid3' => ['namespace', 'string'], + 'uuid4' => [], + 'uuid5' => ['namespace', 'string'], + 'uuid6' => ['node'], + 'uuid7' => ['dateTime'], + 'uuid8' => ['node'], + 'xid' => [], + ]; + + foreach ($methods as $method => $expected) { + $actual = array_map( + static fn(ReflectionParameter $parameter): string => $parameter->getName(), + (new ReflectionMethod(\Infocyph\UID\Id::class, $method))->getParameters(), + ); + expect($actual)->toBe($expected); + } +}); + +test('v5 configuration constructor parameters remain an unchanged prefix', function (): void { + $contracts = [ + \Infocyph\UID\Configuration\SnowflakeConfig::class => [ + 'datacenterId', 'workerId', 'nodeResolver', 'customEpoch', + 'sequenceProvider', 'clockBackwardPolicy', + ], + \Infocyph\UID\Configuration\SonyflakeConfig::class => [ + 'machineId', 'machineIdResolver', 'customEpoch', + 'sequenceProvider', 'clockBackwardPolicy', + ], + \Infocyph\UID\Configuration\TBSLConfig::class => [ + 'machineId', 'sequenced', 'machineIdResolver', + 'sequenceProvider', 'clockBackwardPolicy', + ], + \Infocyph\UID\Configuration\RandflakeConfig::class => [ + 'nodeId', 'leaseStart', 'leaseEnd', 'secret', 'sequenceProvider', + ], + ]; + + foreach ($contracts as $class => $expectedPrefix) { + $constructor = (new ReflectionClass($class))->getConstructor(); + expect($constructor)->not->toBeNull(); + + $names = array_map( + static fn(ReflectionParameter $parameter): string => $parameter->getName(), + $constructor?->getParameters() ?? [], + ); + expect(array_slice($names, 0, count($expectedPrefix)))->toBe($expectedPrefix); + } +}); + +test('legacy Snowflake and Sonyflake stored-layout vectors still parse identically', function (): void { + $epoch = 1_577_836_800_000; + $snowflake = (string) ((1 << 22) | (2 << 17) | (3 << 12) | 4); + $snowflakeParts = \Infocyph\UID\Snowflake::parseWithEpoch($snowflake, $epoch); + + expect($snowflakeParts['sequence'])->toBe(4) + ->and($snowflakeParts['worker_id'])->toBe(3) + ->and($snowflakeParts['datacenter_id'])->toBe(2) + ->and($snowflakeParts['time']->format('Uv'))->toBe((string) ($epoch + 1)); + + $sonyflake = (string) ((1 << 24) | (42 << 8) | 1); + $sonyflakeParts = \Infocyph\UID\Sonyflake::parseWithEpoch($sonyflake, $epoch); + + expect($sonyflakeParts['sequence'])->toBe(1) + ->and($sonyflakeParts['machine_id'])->toBe(42) + ->and($sonyflakeParts['time']->format('Uv'))->toBe((string) ($epoch + 10)); +}); diff --git a/tests/smoke.php b/tests/smoke.php new file mode 100644 index 0000000..844ccda --- /dev/null +++ b/tests/smoke.php @@ -0,0 +1,140 @@ +> $seen */ +function uidSmokeId(string $algorithm, string $id, bool $valid, array &$seen): void +{ + if (!$valid || isset($seen[$algorithm][$id])) { + throw new RuntimeException($algorithm . ' produced an invalid or repeated ID'); + } + + $seen[$algorithm][$id] = true; +} + +$directory = sys_get_temp_dir() . '/uid-smoke-' . bin2hex(random_bytes(6)); +mkdir($directory, 0700) || throw new RuntimeException('Unable to create smoke state directory'); +$provider = new FilesystemSequenceProvider($directory, 'smoke'); +$runtime = new GenerationContext(waitTimeoutMicros: 100_000); +$snowflake = new SnowflakeConfig(datacenterId: 1, workerId: 2, sequenceProvider: $provider, runtime: $runtime); +$sonyflakes = []; +$randflakes = []; +$secret = '0123456789abcdef'; +foreach (SonyflakeFormat::cases() as $format) { + $sonyflakes[] = new SonyflakeConfig(machineId: 42, sequenceProvider: $provider, runtime: $runtime, format: $format); +} +foreach (RandflakeFormat::cases() as $format) { + $randflakes[] = new RandflakeConfig(7, time() - 5, time() + 300, $secret, $provider, $runtime, $format); +} +$tbsl = new TBSLConfig(machineId: 9, sequenceProvider: $provider, runtime: $runtime); +$binaryGenerators = [ + 'ULID monotonic' => [ULID::generateMonotonic(...), ULID::class], + 'ULID random' => [ULID::generateRandom(...), ULID::class], + 'ObjectID' => [ObjectID::generate(...), ObjectID::class], + 'KSUID' => [KSUID::generate(...), KSUID::class], + 'XID' => [XID::generate(...), XID::class], +]; +$randomGenerators = [ + CUID2::class => CUID2::generate(...), + NanoID::class => NanoID::generate(...), + RandomId::class => RandomId::generate(...), +]; +$seen = []; + +try { + for ($cycle = 0; $cycle < 100; ++$cycle) { + foreach ([1, 3, 4, 5, 6, 7, 8] as $version) { + $id = match ($version) { + 1 => UUID::v1(), + 3 => UUID::v3('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle), + 4 => UUID::v4(), + 5 => UUID::v5('00000000-0000-0000-0000-000000000000', 'smoke-' . $cycle), + 6 => UUID::v6(), + 7 => UUID::v7(), + 8 => UUID::v8(), + }; + uidSmokeId('UUID v' . $version, $id, UUID::isValid($id) + && UUID::parse($id)['version'] === $version + && UUID::fromBytes(UUID::toBytes($id)) === $id, $seen); + } + $id = UUID::guid(false); + uidSmokeId('GUID', $id, preg_match('/\A\{[0-9a-f-]{36}\}\z/i', $id) === 1 + && UUID::isValid(trim($id, '{}')), $seen); + + foreach ($binaryGenerators as $name => [$generate, $class]) { + $id = $generate(); + uidSmokeId($name, $id, $class::isValid($id) + && $class::fromBytes($class::toBytes($id)) === $id, $seen); + } + foreach ($randomGenerators as $class => $generate) { + $id = $generate(); + uidSmokeId($class, $id, $class::isValid($id), $seen); + } + $id = TypeID::generate('smoke'); + uidSmokeId('TypeID', $id, TypeID::isValid($id) + && TypeID::fromUuid('smoke', TypeID::toUuid($id)) === $id, $seen); + $id = Snowflake::generateWithConfig($snowflake); + $parsed = Snowflake::parse($id); + uidSmokeId('Snowflake', $id, Snowflake::isValid($id) + && $parsed['datacenter_id'] === 1 && $parsed['worker_id'] === 2 + && Snowflake::fromBytes(Snowflake::toBytes($id)) === $id, $seen); + + foreach ($sonyflakes as $config) { + $id = Sonyflake::generateWithConfig($config); + uidSmokeId('Sonyflake ' . $config->format->value, $id, Sonyflake::isValid($id) + && Sonyflake::parse($id, $config->format)['machine_id'] === 42 + && Sonyflake::fromBytes(Sonyflake::toBytes($id)) === $id, $seen); + } + foreach ($randflakes as $config) { + $id = Randflake::generateWithConfig($config); + uidSmokeId('Randflake ' . $config->format->value, $id, Randflake::isValid($id, $config->format) + && Randflake::inspect($id, $secret, $config->format)['node_id'] === 7 + && Randflake::fromBytes(Randflake::toBytes($id, $config->format), $config->format) === $id, $seen); + } + foreach (['sequenced' => TBSL::generateWithConfig($tbsl), 'random' => TBSL::generateRandom(9)] as $mode => $id) { + uidSmokeId('TBSL ' . $mode, $id, TBSL::isValid($id) + && TBSL::parse($id)['machineId'] === 9 + && TBSL::fromBytes(TBSL::toBytes($id)) === $id, $seen); + } + $id = OpaqueId::fromInt($cycle, 'smoke'); + uidSmokeId('OpaqueId', $id, OpaqueId::toInt($id, 'smoke') === $cycle, $seen); + $id = DeterministicId::fromPayload('smoke-' . $cycle, 24, 'smoke'); + uidSmokeId('DeterministicId', $id, RandomId::isValid($id, 24, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') + && DeterministicId::fromPayload('smoke-' . $cycle, 24, 'smoke') === $id, $seen); + } + foreach ($seen as $algorithm => $ids) { + printf("PASS %s: %d IDs\n", $algorithm, count($ids)); + } + printf("Passed %d generator variants in one 100-cycle smoke pass.\n", count($seen)); +} finally { + foreach (glob($directory . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($directory); +}