From 258e99a7232d8137db0c7ee2e6a5a3dbe8f95af0 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Wed, 12 Aug 2026 11:50:23 +0800 Subject: [PATCH 1/2] fix: unify TypeID validation boundaries Route all caller-invalid data through ValidationException, preserve value-object invariants during unserialization, and make raw bytes the Base32 conversion core. --- README.md | 4 +- src/Base32.php | 71 +++++++----- src/Exception/ConstructorException.php | 2 +- src/Exception/ValidationException.php | 2 +- src/TypeID.php | 93 +++++++-------- src/Validator.php | 64 +++------- tests/Invalid.php | 4 +- tests/TypeIDTest.php | 154 ++++++++++++++++--------- 8 files changed, 211 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 6efd2fa..ea3b64d 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ $b = TypeID::fromString('user_01jsnsf2g7e2saxdjvz3j6tc3x'); echo $a->equals($b); // true // Round-trip a UUID stored in a binary(16) database column -$uuidBytes = random_bytes(16); +$uuidBytes = TypeID::generate('user')->bytes(); $binaryId = TypeID::fromBytes($uuidBytes, 'user'); $uuidBytes = $binaryId->bytes(); ``` @@ -69,7 +69,7 @@ The package uses `ramsey/uuid` to generate standards-compliant UUIDv7 values. En `fromUuid()` also accepts valid non-v7 UUIDs for interoperability. Those imported values—and the nil value returned by `zero()`—do not gain UUIDv7 chronological ordering merely by being encoded as TypeIDs. -All package exceptions implement `TypeID\Exception\TypeIDException`, allowing callers to catch construction and validation failures through one stable contract. +Caller-invalid input throws `TypeID\Exception\ValidationException`, which extends `InvalidArgumentException`. `TypeID\Exception\ConstructorException` is reserved for UUID generation failures. Both implement `TypeID\Exception\TypeIDException`. ## Format diff --git a/src/Base32.php b/src/Base32.php index 0aa220b..b25106b 100644 --- a/src/Base32.php +++ b/src/Base32.php @@ -4,7 +4,7 @@ namespace TypeID; -use InvalidArgumentException; +use TypeID\Exception\ValidationException; /** * Crockford base32 encoder/decoder for TypeID suffixes. @@ -49,28 +49,35 @@ private function __construct() {} /** * Encode a UUID string to a 26-char Crockford base32 suffix. * - * @throws InvalidArgumentException If $uuid is not a valid UUID. + * @throws ValidationException If $uuid is not a valid UUID. */ public static function encode(string $uuid): string { if (! Validator::isValidUuid($uuid)) { - throw new InvalidArgumentException('Invalid UUID string: '.$uuid); + throw new ValidationException( + 'Invalid UUID string: '.Validator::formatForMessage($uuid) + ); } - $binary = hex2bin(str_replace('-', '', strtolower($uuid))); - - if ($binary === false) { - throw new InvalidArgumentException('Invalid UUID string: '.$uuid); - } - - $bytes = unpack('C*', $binary); + return self::encodeBytes(hex2bin(str_replace('-', '', strtolower($uuid)))); + } - if ($bytes === false) { - throw new InvalidArgumentException('Invalid UUID string: '.$uuid); + /** + * Encode 16 raw UUID bytes to a 26-char Crockford base32 suffix. + * + * @throws ValidationException If $bytes is not exactly 16 bytes. + */ + public static function encodeBytes(string $bytes): string + { + if (strlen($bytes) !== 16) { + throw new ValidationException( + 'UUID bytes must be exactly 16 bytes, got '.strlen($bytes) + ); } - /** @var int[] $b */ - $b = array_values($bytes); + /** @var array $unpacked */ + $unpacked = unpack('C*', $bytes); + $b = array_values($unpacked); $a = self::ALPHABET; @@ -107,18 +114,38 @@ public static function encode(string $uuid): string * Decode a 26-char Crockford base32 suffix to its canonical UUID string. * Input is strict: lowercase only, with no ambiguous Crockford characters. * - * @throws InvalidArgumentException If $base32 is not a valid 26-char Crockford string. + * @throws ValidationException If $base32 is not a valid 26-char Crockford string. */ public static function decode(string $base32): string { - if (! Validator::isValidBase32($base32)) { - throw new InvalidArgumentException('Invalid TypeID base32 string: '.$base32); + $hex = bin2hex(self::decodeBytes($base32)); + + return sprintf('%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); + } + + /** + * Decode a 26-char Crockford base32 suffix to 16 raw UUID bytes. + * + * @throws ValidationException If $base32 is not a valid TypeID suffix. + */ + public static function decodeBytes(string $base32): string + { + if (! Validator::isValidSuffix($base32)) { + throw new ValidationException( + 'Invalid TypeID base32 string: '.Validator::formatForMessage($base32) + ); } $m = self::DECODE_MAP; $v = array_map(fn (string $ch): int => $m[$ch], str_split($base32)); - $hex = bin2hex(pack('C*', + return pack('C*', $v[0] << 5 | $v[1], $v[2] << 3 | $v[3] >> 2, ($v[3] & 0x03) << 6 | $v[4] << 1 | $v[5] >> 4, @@ -135,14 +162,6 @@ public static function decode(string $base32): string ($v[21] & 0x0F) << 4 | $v[22] >> 1, ($v[22] & 0x01) << 7 | $v[23] << 2 | $v[24] >> 3, ($v[24] & 0x07) << 5 | $v[25], - )); - - return sprintf('%s-%s-%s-%s-%s', - substr($hex, 0, 8), - substr($hex, 8, 4), - substr($hex, 12, 4), - substr($hex, 16, 4), - substr($hex, 20, 12), ); } } diff --git a/src/Exception/ConstructorException.php b/src/Exception/ConstructorException.php index d2782db..1dd019d 100644 --- a/src/Exception/ConstructorException.php +++ b/src/Exception/ConstructorException.php @@ -6,5 +6,5 @@ use RuntimeException; -/** Thrown when a TypeID cannot be constructed from the given input. */ +/** Thrown when TypeID generation fails operationally. */ final class ConstructorException extends RuntimeException implements TypeIDException {} diff --git a/src/Exception/ValidationException.php b/src/Exception/ValidationException.php index 8ef0e4b..7509353 100644 --- a/src/Exception/ValidationException.php +++ b/src/Exception/ValidationException.php @@ -6,5 +6,5 @@ use InvalidArgumentException; -/** Thrown when a TypeID prefix or suffix fails spec validation. */ +/** Thrown when caller input fails TypeID validation. */ final class ValidationException extends InvalidArgumentException implements TypeIDException {} diff --git a/src/TypeID.php b/src/TypeID.php index 3af1990..18b3693 100644 --- a/src/TypeID.php +++ b/src/TypeID.php @@ -4,10 +4,9 @@ namespace TypeID; -use Exception; -use InvalidArgumentException; use JsonSerializable; use Override; +use Ramsey\Uuid\Exception\UuidExceptionInterface; use Ramsey\Uuid\Uuid; use Stringable; use TypeID\Exception\ConstructorException; @@ -35,11 +34,15 @@ public function __construct( public readonly string $suffix, // Crockford base32 UUID payload — always exactly 26 lowercase characters. ) { if (! Validator::isValidPrefix($this->prefix)) { - throw new ValidationException("Invalid prefix: {$this->prefix}"); + throw new ValidationException( + 'Invalid prefix: '.Validator::formatForMessage($this->prefix) + ); } if (! Validator::isValidSuffix($this->suffix)) { - throw new ValidationException("Invalid suffix: {$this->suffix}"); + throw new ValidationException( + 'Invalid suffix: '.Validator::formatForMessage($this->suffix) + ); } } @@ -49,45 +52,52 @@ public function __toString(): string return $this->toString(); } + /** @return array{prefix: string, suffix: string} */ + public function __serialize(): array + { + return [ + 'prefix' => $this->prefix, + 'suffix' => $this->suffix, + ]; + } + + /** + * @param array $data + * + * @throws ValidationException If the serialized data is malformed or invalid. + */ + public function __unserialize(array $data): void + { + if (! is_string($data['prefix'] ?? null) || ! is_string($data['suffix'] ?? null)) { + throw new ValidationException('Invalid serialized TypeID data'); + } + + $validated = new self($data['prefix'], $data['suffix']); + + $this->prefix = $validated->prefix; + $this->suffix = $validated->suffix; + } + /** * Create a TypeID from any valid UUID string (v4, v7, nil, …). * Uppercase hex is accepted and normalized to lowercase. * - * @throws ConstructorException If $uuid is not a valid UUID string. - * @throws ValidationException If $prefix fails spec validation. + * @throws ValidationException If $uuid or $prefix fails validation. */ public static function fromUuid(string $uuid, ?string $prefix = null): self { - try { - $suffix = Base32::encode($uuid); - } catch (InvalidArgumentException $e) { - throw new ConstructorException( - 'Failed to create TypeID from UUID: '.$e->getMessage(), - previous: $e, - ); - } - - return new self($prefix ?? '', $suffix); + return new self($prefix ?? '', Base32::encode($uuid)); } /** * Create a TypeID from a prefix and raw 16-byte binary UUID. * Useful for round-tripping UUIDs stored as binary(16) in a database. * - * @throws ConstructorException If $bytes is not exactly 16 bytes. - * @throws ValidationException If $prefix fails spec validation. + * @throws ValidationException If $bytes or $prefix fails validation. */ public static function fromBytes(string $bytes, ?string $prefix = null): self { - if (strlen($bytes) !== 16) { - throw new ConstructorException( - 'UUID bytes must be exactly 16 bytes, got '.strlen($bytes) - ); - } - - $uuid = Uuid::fromBytes($bytes)->toString(); - - return self::fromUuid($uuid, $prefix); + return new self($prefix ?? '', Base32::encodeBytes($bytes)); } /** @@ -95,20 +105,13 @@ public static function fromBytes(string $bytes, ?string $prefix = null): self * Accepts prefixed ('user_01jsnsf2g7…') and bare ('01jsnsf2g7…') forms. * The last underscore is always the prefix/suffix delimiter. * - * @throws ConstructorException If $value is empty, malformed, or fails spec validation. + * @throws ValidationException If $value is malformed or fails spec validation. */ public static function fromString(string $value): self { - try { - [$prefix, $suffix] = Validator::parseTypeID($value); + [$prefix, $suffix] = Validator::parseTypeID($value); - return new self($prefix, $suffix); - } catch (InvalidArgumentException $e) { - throw new ConstructorException( - 'Failed to create TypeID from string: '.$e->getMessage(), - previous: $e, - ); - } + return new self($prefix, $suffix); } /** @@ -123,14 +126,14 @@ public static function generate(?string $prefix = null): self { try { $uuid = Uuid::uuid7()->toString(); - } catch (Exception $e) { + } catch (UuidExceptionInterface $e) { throw new ConstructorException( 'Failed to generate TypeID: '.$e->getMessage(), previous: $e, ); } - return self::fromUuid($uuid, $prefix ?? ''); + return self::fromUuid($uuid, $prefix); } /** @@ -159,7 +162,7 @@ public function toUuid(): string /** Decode the suffix to raw 16-byte binary — useful for binary(16) database columns. */ public function bytes(): string { - return Uuid::fromString($this->toUuid())->getBytes(); + return Base32::decodeBytes($this->suffix); } /** True when this TypeID represents the nil UUID (all 128 bits are zero). */ @@ -174,16 +177,6 @@ public function isNonZero(): bool return ! $this->isZero(); } - /** - * True when this TypeID has a non-zero suffix. - * - * @deprecated Use isNonZero() instead; every TypeID has a suffix. - */ - public function hasSuffix(): bool - { - return $this->isNonZero(); - } - /** True when this TypeID's prefix exactly matches $prefix (case-sensitive). */ public function hasPrefix(string $prefix): bool { diff --git a/src/Validator.php b/src/Validator.php index 15b0282..15e60db 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -4,7 +4,7 @@ namespace TypeID; -use InvalidArgumentException; +use TypeID\Exception\ValidationException; /** * Stateless validation helpers for TypeID components. @@ -14,8 +14,6 @@ */ final class Validator { - private const int MAX_PREFIX_LENGTH = 63; - /** * Prefix rules: lowercase a-z only; may contain underscores but not at * the start or end; max 63 chars. Empty string is valid (no prefix). @@ -32,10 +30,7 @@ private function __construct() {} public static function isValidPrefix(string $prefix): bool { - return $prefix === '' || ( - strlen($prefix) <= self::MAX_PREFIX_LENGTH && - (bool) preg_match(self::PREFIX_PATTERN, $prefix) - ); + return preg_match(self::PREFIX_PATTERN, $prefix) === 1; } /** @@ -54,40 +49,28 @@ public static function isValidSuffix(string $suffix): bool * * @return array{0: string, 1: string} * - * @throws InvalidArgumentException + * @throws ValidationException */ public static function parseTypeID(string $value): array { if ($value === '') { - throw new InvalidArgumentException('TypeID string cannot be empty'); + throw new ValidationException('TypeID string cannot be empty'); } - $lastUnderscore = strrpos($value, '_'); - - if ($lastUnderscore === 0) { - throw new InvalidArgumentException('TypeID string cannot start with an underscore'); + if (str_starts_with($value, '_')) { + throw new ValidationException('TypeID string cannot start with an underscore'); } - if ($lastUnderscore === false) { - if (! self::isValidSuffix($value)) { - throw new InvalidArgumentException('Invalid TypeID suffix: '.$value); - } + $lastUnderscore = strrpos($value, '_'); + if ($lastUnderscore === false) { return ['', $value]; } - $prefix = substr($value, 0, $lastUnderscore); - $suffix = substr($value, $lastUnderscore + 1); - - if (! self::isValidPrefix($prefix)) { - throw new InvalidArgumentException('Invalid TypeID prefix: '.$prefix); - } - - if (! self::isValidSuffix($suffix)) { - throw new InvalidArgumentException('Invalid TypeID suffix: '.$suffix); - } - - return [$prefix, $suffix]; + return [ + substr($value, 0, $lastUnderscore), + substr($value, $lastUnderscore + 1), + ]; } /** Accepts UUID with or without dashes, case-insensitive. */ @@ -96,25 +79,12 @@ public static function isValidUuid(string $uuid): bool return preg_match(self::UUID_PATTERN, $uuid) === 1; } - /** - * Validates UUIDv7 structure: - * - hex[12] must be '7' → version bits (48-51) = 0111 - * - hex[16] must be 8/9/a/b → variant bits (64-65) = 10xx (RFC 4122) - */ - public static function isValidUuidv7(string $uuid): bool + public static function formatForMessage(string $value): string { - if (! self::isValidUuid($uuid)) { - return false; - } + $escaped = addcslashes($value, "\0..\37"); - $hex = strtolower(str_replace('-', '', $uuid)); - - return $hex[12] === '7' && in_array($hex[16], ['8', '9', 'a', 'b'], strict: true); - } - - /** Alias for isValidSuffix — used internally by Base32. */ - public static function isValidBase32(string $base32): bool - { - return self::isValidSuffix($base32); + return strlen($escaped) > 64 + ? substr($escaped, 0, 64).'...' + : $escaped; } } diff --git a/tests/Invalid.php b/tests/Invalid.php index 6be8de2..8c467cf 100644 --- a/tests/Invalid.php +++ b/tests/Invalid.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use TypeID\Exception\ConstructorException; +use TypeID\Exception\ValidationException; use TypeID\TypeID; $invalidJson = file_get_contents(__DIR__.'/../spec/invalid.json'); @@ -19,5 +19,5 @@ )); test('reject invalid typeids', function (string $typeid): void { - expect(fn () => TypeID::fromString($typeid))->toThrow(ConstructorException::class); + expect(fn () => TypeID::fromString($typeid))->toThrow(ValidationException::class); })->with('invalid typeids'); diff --git a/tests/TypeIDTest.php b/tests/TypeIDTest.php index b00b214..9685ce3 100644 --- a/tests/TypeIDTest.php +++ b/tests/TypeIDTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use TypeID\Base32; -use TypeID\Exception\ConstructorException; use TypeID\Exception\TypeIDException; use TypeID\Exception\ValidationException; use TypeID\TypeID; @@ -51,11 +50,15 @@ test('generate random TypeID with prefix', function (): void { $typeId = TypeID::generate('user'); + $uuidHex = str_replace('-', '', $typeId->toUuid()); + expect($typeId)->toBeInstanceOf(TypeID::class); expect($typeId->prefix)->toBe('user'); expect(strlen($typeId->suffix))->toBe(26); expect($typeId->toString())->toStartWith('user_'); expect($typeId->isZero())->toBeFalse(); + expect($uuidHex[12])->toBe('7'); + expect(hexdec($uuidHex[16]) & 0xC)->toBe(0x8); }); test('generate random TypeID without prefix', function (): void { @@ -67,6 +70,17 @@ expect($typeId->isZero())->toBeFalse(); }); +test('generated TypeIDs with the same prefix are K-sortable', function (): void { + $previous = ''; + + for ($i = 0; $i < 3000; $i++) { + $current = TypeID::generate('event')->toString(); + + expect(strcmp($current, $previous))->toBeGreaterThanOrEqual(0); + $previous = $current; + } +}); + test('generate with invalid prefix throws exception', function (): void { expect(fn () => TypeID::generate('Invalid-Prefix')) ->toThrow(ValidationException::class, 'Invalid prefix: Invalid-Prefix'); @@ -111,12 +125,12 @@ test('fromString with empty string throws exception', function (): void { expect(fn () => TypeID::fromString('')) - ->toThrow(ConstructorException::class, 'Failed to create TypeID from string: TypeID string cannot be empty'); + ->toThrow(ValidationException::class, 'TypeID string cannot be empty'); }); test('fromString with invalid TypeID format throws exception', function (): void { expect(fn () => TypeID::fromString('user-01jsnsf2g7e2saxdjvz3j6tc3x')) - ->toThrow(ConstructorException::class, 'Failed to create TypeID from string: Invalid TypeID suffix: user-01jsnsf2g7e2saxdjvz3j6tc3x'); + ->toThrow(ValidationException::class, 'Invalid suffix: user-01jsnsf2g7e2saxdjvz3j6tc3x'); }); test('fromUuid with valid UUIDv7', function (): void { @@ -137,7 +151,7 @@ test('fromUuid with invalid UUID throws exception', function (): void { expect(fn () => TypeID::fromUuid('not-a-uuid', 'user')) - ->toThrow(ConstructorException::class, 'Failed to create TypeID from UUID: Invalid UUID string: not-a-uuid'); + ->toThrow(ValidationException::class, 'Invalid UUID string: not-a-uuid'); }); test('fromUuid with non-UUIDv7 succeeds', function (): void { @@ -192,9 +206,24 @@ expect(Base32::decode($encoded))->toBe($uuid); }); +test('Base32 byte encoding and decoding roundtrip', function (): void { + $bytes = implode('', array_map(chr(...), range(0, 15))); + $encoded = Base32::encodeBytes($bytes); + + expect($encoded)->toHaveLength(26); + expect(Base32::decodeBytes($encoded))->toBe($bytes); +}); + +test('Base32 encodeBytes rejects values that are not exactly 16 bytes', function (string $bytes): void { + expect(fn () => Base32::encodeBytes($bytes))->toThrow(ValidationException::class); +})->with([ + 'too short' => str_repeat("\0", 15), + 'too long' => str_repeat("\0", 17), +]); + test('Base32 encode with malformed UUID throws exception', function (): void { expect(fn () => Base32::encode('not-a-uuid')) - ->toThrow(\InvalidArgumentException::class, 'Invalid UUID string: not-a-uuid'); + ->toThrow(ValidationException::class, 'Invalid UUID string: not-a-uuid'); }); test('Base32 encode with valid non-UUIDv7 succeeds', function (): void { @@ -212,16 +241,16 @@ test('Base32 decode with invalid characters throws exception', function (): void { expect(fn () => Base32::decode('ill3g4l-ch4r4ct3rs-in-b4s332')) - ->toThrow(\InvalidArgumentException::class, 'Invalid TypeID base32 string: ill3g4l-ch4r4ct3rs-in-b4s332'); + ->toThrow(ValidationException::class, 'Invalid TypeID base32 string: ill3g4l-ch4r4ct3rs-in-b4s332'); }); test('Base32 decode with wrong length throws exception', function (): void { expect(fn () => Base32::decode('tooshort')) - ->toThrow(\InvalidArgumentException::class, 'Invalid TypeID base32 string: tooshort'); + ->toThrow(ValidationException::class, 'Invalid TypeID base32 string: tooshort'); }); test('Base32 decode rejects non-canonical Crockford input', function (string $suffix): void { - expect(fn () => Base32::decode($suffix))->toThrow(\InvalidArgumentException::class); + expect(fn () => Base32::decode($suffix))->toThrow(ValidationException::class); })->with([ 'uppercase' => '01JsNsF2g7E2sAxDjVz3J6tC3x', 'ambiguous characters' => '0Ijsnsf2g7e2saxdjvz3jltc3x', @@ -289,18 +318,27 @@ ['very_long_prefix_01jsnsf2g7e2saxdjvz3j6tc3x', ['very_long_prefix', '01jsnsf2g7e2saxdjvz3j6tc3x']], ]); -test('Validator parseTypeID with invalid TypeIDs throws exception', function (string $typeId): void { +test('Validator parseTypeID only rejects empty and leading underscore strings', function (string $typeId): void { expect(fn () => Validator::parseTypeID($typeId)) - ->toThrow(\InvalidArgumentException::class); + ->toThrow(ValidationException::class); })->with([ '', - 'invalid-typeid', - 'prefix_invalid_suffix', - 'Invalid_01jsnsf2g7e2saxdjvz3j6tc3x', - 'user__01jsnsf2g7e2saxdjvz3j6tc3x', '__01jsnsf2g7e2saxdjvz3j6tc3x', ]); +test('Validator parseTypeID leaves component validation to TypeID', function ( + string $typeId, + array $expected, +): void { + expect(Validator::parseTypeID($typeId))->toBe($expected); + expect(fn () => TypeID::fromString($typeId))->toThrow(ValidationException::class); +})->with([ + ['invalid-typeid', ['', 'invalid-typeid']], + ['prefix_invalid_suffix', ['prefix_invalid', 'suffix']], + ['Invalid_01jsnsf2g7e2saxdjvz3j6tc3x', ['Invalid', '01jsnsf2g7e2saxdjvz3j6tc3x']], + ['user__01jsnsf2g7e2saxdjvz3j6tc3x', ['user_', '01jsnsf2g7e2saxdjvz3j6tc3x']], +]); + test('Validator isValidUuid with valid UUIDs', function (string $uuid): void { expect(Validator::isValidUuid($uuid))->toBeTrue(); })->with([ @@ -323,23 +361,6 @@ "01966b97-8a07-70b2-aeb6-5bf8e46d307d\n", // Trailing newline ]); -test('Validator isValidUuidv7 with valid UUIDv7s', function (string $uuid): void { - expect(Validator::isValidUuidv7($uuid))->toBeTrue(); -})->with([ - '01966b97-8a07-70b2-aeb6-5bf8e46d307d', // With dashes - '01966b978a0770b2aeb65bf8e46d307d', // Without dashes -]); - -test('Validator isValidUuidv7 with invalid UUIDv7s', function (string $uuid, ?string $description = null): void { - expect(Validator::isValidUuidv7($uuid))->toBeFalse(); -})->with([ - ['', 'Empty string'], - ['not-a-uuid', 'Not a UUID'], - ['f47ac10b-58cc-4372-a567-0e02b2c3d479', 'UUIDv4'], - ['01966b97-8a07-10b2-aeb6-5bf8e46d307d', 'Wrong version bits (1 instead of 7)'], - ['01966b97-8a07-70b2-2eb6-5bf8e46d307d', 'Wrong variant bits (2 instead of a/b)'], -]); - // ===== Edge Cases and Robustness Tests ===== test('TypeID roundtrip with various prefixes and UUIDs', function (string $prefix, string $uuid): void { @@ -438,22 +459,6 @@ expect($parsed->toUuid())->toBe('01966b97-8a07-70b2-aeb6-5bf8e46d307d'); }); -test('Validator isValidUuidv7 rejects non-RFC-4122 variant bits', function (string $uuid): void { - expect(Validator::isValidUuidv7($uuid))->toBeFalse(); -})->with([ - '01966b97-8a07-70b2-ceb6-5bf8e46d307d', // variant c (11xx) - '01966b97-8a07-70b2-deb6-5bf8e46d307d', // variant d (11xx) -]); - -test('ConstructorException from invalid UUID preserves previous exception', function (): void { - try { - TypeID::fromUuid('not-a-uuid', 'user'); - expect(false)->toBeTrue(); // should not reach here - } catch (ConstructorException $e) { - expect($e->getPrevious())->toBeInstanceOf(\InvalidArgumentException::class); - } -}); - test('all package exceptions share a catchable domain contract', function (): void { foreach ([ fn () => TypeID::fromUuid('not-a-uuid'), @@ -471,16 +476,42 @@ } }); +test('caller-invalid input is catchable as InvalidArgumentException', function (): void { + expect(fn () => TypeID::fromString('invalid')) + ->toThrow(\InvalidArgumentException::class); +}); + test('validation rejects final newlines without leaking native errors', function (): void { $shortSuffixWithNewline = str_repeat('0', 25)."\n"; expect(Validator::isValidPrefix("user\n"))->toBeFalse(); expect(Validator::isValidSuffix($shortSuffixWithNewline))->toBeFalse(); - expect(fn () => TypeID::fromString($shortSuffixWithNewline))->toThrow(ConstructorException::class); + expect(fn () => TypeID::fromString($shortSuffixWithNewline))->toThrow(ValidationException::class); expect(fn () => TypeID::fromUuid("01966b97-8a07-70b2-aeb6-5bf8e46d307d\n")) - ->toThrow(ConstructorException::class); + ->toThrow(ValidationException::class); expect(fn () => TypeID::fromUuid('01966b97-8a0770b2-aeb6-5bf8e46d307d')) - ->toThrow(ConstructorException::class); + ->toThrow(ValidationException::class); +}); + +test('rejected values are escaped and truncated in exception messages', function (): void { + $invalid = "INVALID\n".str_repeat('x', 100); + + foreach ([ + fn () => new TypeID($invalid, TypeID::ZERO_SUFFIX), + fn () => Base32::encode($invalid), + fn () => Base32::decode($invalid), + ] as $operation) { + try { + $operation(); + } catch (ValidationException $exception) { + expect($exception->getMessage())->not->toContain("\n"); + expect(strlen($exception->getMessage()) < 100)->toBeTrue(); + + continue; + } + + throw new \RuntimeException('Expected a validation exception'); + } }); test('binary conversion roundtrips all byte values', function (): void { @@ -492,7 +523,7 @@ }); test('fromBytes rejects values that are not exactly 16 bytes', function (string $bytes): void { - expect(fn () => TypeID::fromBytes($bytes))->toThrow(ConstructorException::class); + expect(fn () => TypeID::fromBytes($bytes))->toThrow(ValidationException::class); })->with([ 'too short' => str_repeat("\0", 15), 'too long' => str_repeat("\0", 17), @@ -504,12 +535,27 @@ expect(json_encode($typeId, JSON_THROW_ON_ERROR))->toBe('"user_01jsnsf2g7e2saxdjvz3j6tc3x"'); }); +test('native serialization roundtrips a TypeID', function (): void { + $typeId = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); + $restored = unserialize(serialize($typeId)); + + expect($restored)->toBeInstanceOf(TypeID::class); + expect($restored->equals($typeId))->toBeTrue(); +}); + +test('native unserialization rejects corrupt TypeID data', function (): void { + $typeId = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); + $serialized = serialize($typeId); + $corrupt = str_replace('s:4:"user";', 's:7:"INVALID";', $serialized); + + expect($corrupt)->not->toBe($serialized); + expect(fn () => unserialize($corrupt))->toThrow(ValidationException::class); +}); + test('non-zero helpers distinguish nil and populated suffixes', function (): void { $zero = TypeID::zero('user'); $nonZero = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); expect($zero->isNonZero())->toBeFalse() - ->and($zero->hasSuffix())->toBeFalse() - ->and($nonZero->isNonZero())->toBeTrue() - ->and($nonZero->hasSuffix())->toBeTrue(); + ->and($nonZero->isNonZero())->toBeTrue(); }); From 9b2ce3c0f9d33ec15cb1d22371d290b7dca714f2 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Wed, 12 Aug 2026 11:54:59 +0800 Subject: [PATCH 2/2] chore: trim comment debt after contract hardening Restore the Base32 bit-layout map (needed to maintain the unrolled encoder), assert unpack failures, and give mute Pest datasets named keys. --- src/Base32.php | 11 ++++---- src/TypeID.php | 4 +-- src/Validator.php | 13 ---------- tests/TypeIDTest.php | 61 +++++++++++++++----------------------------- 4 files changed, 28 insertions(+), 61 deletions(-) diff --git a/src/Base32.php b/src/Base32.php index b25106b..18cf16f 100644 --- a/src/Base32.php +++ b/src/Base32.php @@ -9,10 +9,6 @@ /** * Crockford base32 encoder/decoder for TypeID suffixes. * - * Converts a 128-bit UUID to/from a 26-character string using Crockford's - * alphabet (0-9, a-z minus i, l, o, u). Pure bit manipulation — no GMP - * or bcmath required. - * * Bit layout — 16 UUID bytes (128 bits) → 26 × 5-bit chars: * * c[ 0] = b[0]>>5 bits 127-125 (top 2 always 0 → max char is '7') @@ -33,7 +29,6 @@ final class Base32 { private const string ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; - /** Reverse lookup: Crockford char → 5-bit integer value. */ private const array DECODE_MAP = [ '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9, @@ -75,8 +70,12 @@ public static function encodeBytes(string $bytes): string ); } - /** @var array $unpacked */ $unpacked = unpack('C*', $bytes); + + if ($unpacked === false) { + throw new ValidationException('Failed to unpack UUID bytes'); + } + $b = array_values($unpacked); $a = self::ALPHABET; diff --git a/src/TypeID.php b/src/TypeID.php index 18b3693..c9fcf14 100644 --- a/src/TypeID.php +++ b/src/TypeID.php @@ -30,8 +30,8 @@ final class TypeID implements JsonSerializable, Stringable /** @throws ValidationException If prefix or suffix fails TypeID spec validation. */ public function __construct( - public readonly string $prefix, // Entity-type label (e.g. 'user', 'order'). Empty string means no prefix. - public readonly string $suffix, // Crockford base32 UUID payload — always exactly 26 lowercase characters. + public readonly string $prefix, + public readonly string $suffix, ) { if (! Validator::isValidPrefix($this->prefix)) { throw new ValidationException( diff --git a/src/Validator.php b/src/Validator.php index 15e60db..addfb9b 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -8,22 +8,15 @@ /** * Stateless validation helpers for TypeID components. - * All methods are static — this class is not meant to be instantiated. * * @internal Use TypeID for the stable public API. */ final class Validator { - /** - * Prefix rules: lowercase a-z only; may contain underscores but not at - * the start or end; max 63 chars. Empty string is valid (no prefix). - */ private const string PREFIX_PATTERN = '/\A(?:[a-z](?:[a-z_]{0,61}[a-z])?)?\z/'; - /** Exactly 128 bits encoded with the strict TypeID base32 alphabet. */ private const string SUFFIX_PATTERN = '/\A[0-7][0123456789abcdefghjkmnpqrstvwxyz]{25}\z/'; - /** Canonical UUID format, or the same 32 hexadecimal digits without dashes. */ private const string UUID_PATTERN = '/\A(?:[0-9a-f]{32}|[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12})\z/i'; private function __construct() {} @@ -33,11 +26,6 @@ public static function isValidPrefix(string $prefix): bool return preg_match(self::PREFIX_PATTERN, $prefix) === 1; } - /** - * A valid suffix is exactly 26 Crockford chars whose value fits in 128 bits. - * 26 × 5 = 130 bits, but the max encodable value is '7zzz…' (first char ≤ '7'), - * capping the range to exactly 2^128 - 1. - */ public static function isValidSuffix(string $suffix): bool { return preg_match(self::SUFFIX_PATTERN, $suffix) === 1; @@ -73,7 +61,6 @@ public static function parseTypeID(string $value): array ]; } - /** Accepts UUID with or without dashes, case-insensitive. */ public static function isValidUuid(string $uuid): bool { return preg_match(self::UUID_PATTERN, $uuid) === 1; diff --git a/tests/TypeIDTest.php b/tests/TypeIDTest.php index 9685ce3..eb60c99 100644 --- a/tests/TypeIDTest.php +++ b/tests/TypeIDTest.php @@ -8,8 +8,6 @@ use TypeID\TypeID; use TypeID\Validator; -// ===== TypeID Creation and Parsing Tests ===== - test('create TypeID with valid prefix and suffix', function (): void { $typeId = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); expect($typeId->prefix)->toBe('user'); @@ -46,8 +44,6 @@ expect((string) $typeId)->toBe('user_01jsnsf2g7e2saxdjvz3j6tc3x'); }); -// ===== TypeID Factory Methods Tests ===== - test('generate random TypeID with prefix', function (): void { $typeId = TypeID::generate('user'); $uuidHex = str_replace('-', '', $typeId->toUuid()); @@ -107,8 +103,6 @@ ->toThrow(ValidationException::class, 'Invalid prefix: Invalid-Prefix'); }); -// ===== TypeID Conversion Tests ===== - test('fromString with valid TypeID string', function (): void { $typeId = TypeID::fromString('user_01jsnsf2g7e2saxdjvz3j6tc3x'); expect($typeId)->toBeInstanceOf(TypeID::class); @@ -155,7 +149,6 @@ }); test('fromUuid with non-UUIDv7 succeeds', function (): void { - // UUIDv4 should now encode successfully $uuidv4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; $typeId = TypeID::fromUuid($uuidv4, 'user'); expect($typeId)->toBeInstanceOf(TypeID::class); @@ -168,8 +161,6 @@ expect($typeId->toUuid())->toBe($uuid); }); -// ===== TypeID Comparison Methods Tests ===== - test('TypeID equals with identical TypeIDs', function (): void { $typeId1 = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); $typeId2 = new TypeID('user', '01jsnsf2g7e2saxdjvz3j6tc3x'); @@ -198,8 +189,6 @@ expect($typeId->hasPrefix('post'))->toBeFalse(); }); -// ===== Base32 Encoding/Decoding Tests ===== - test('Base32 encode and decode roundtrip', function (): void { $uuid = '01966b97-8a07-70b2-aeb6-5bf8e46d307d'; $encoded = Base32::encode($uuid); @@ -227,7 +216,6 @@ }); test('Base32 encode with valid non-UUIDv7 succeeds', function (): void { - // UUIDv4 should encode successfully $uuidv4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; $encoded = Base32::encode($uuidv4); expect($encoded)->toHaveLength(26); @@ -256,8 +244,6 @@ 'ambiguous characters' => '0Ijsnsf2g7e2saxdjvz3jltc3x', ]); -// ===== Validator Tests ===== - test('Validator isValidPrefix with valid prefixes', function (string $prefix): void { expect(Validator::isValidPrefix($prefix))->toBeTrue(); })->with([ @@ -270,7 +256,7 @@ 'a_b_c', 'prefix_with_underscore', 'multiple__underscores', - str_repeat('a', 63), // Max length + str_repeat('a', 63), ]); test('Validator isValidPrefix with invalid prefixes', function (string $prefix): void { @@ -288,7 +274,7 @@ '_prefix', 'prefix_', '__prefix', - str_repeat('a', 64), // Too long + str_repeat('a', 64), ]); test('Validator isValidSuffix with valid suffixes', function (string $suffix): void { @@ -301,12 +287,12 @@ test('Validator isValidSuffix with invalid suffixes', function (string $suffix): void { expect(Validator::isValidSuffix($suffix))->toBeFalse(); })->with([ - '0', - str_repeat('0', 25), // Too short - str_repeat('0', 27), // Too long - '01jsnsf2g7e2saxdjvOILz3j6tc', // Contains invalid chars O, I, L - '01jsnsf2g7e2saxdjvz3j6tc3X', // Contains uppercase - '01JSNSF2G7E2SAXDJVZ3J6TC3X', // Contains uppercase only + 'too short single' => '0', + 'too short 25' => str_repeat('0', 25), + 'too long 27' => str_repeat('0', 27), + 'invalid chars OIL' => '01jsnsf2g7e2saxdjvOILz3j6tc', + 'trailing uppercase' => '01jsnsf2g7e2saxdjvz3j6tc3X', + 'all uppercase' => '01JSNSF2G7E2SAXDJVZ3J6TC3X', ]); test('Validator parseTypeID with valid TypeIDs', function (string $typeId, array $expected): void { @@ -342,10 +328,10 @@ test('Validator isValidUuid with valid UUIDs', function (string $uuid): void { expect(Validator::isValidUuid($uuid))->toBeTrue(); })->with([ - '01966b97-8a07-70b2-aeb6-5bf8e46d307d', // With dashes - '01966b978a0770b2aeb65bf8e46d307d', // Without dashes - '00000000-0000-0000-0000-000000000000', // Zero UUID - 'f47ac10b-58cc-4372-a567-0e02b2c3d479', // UUIDv4 + '01966b97-8a07-70b2-aeb6-5bf8e46d307d', + '01966b978a0770b2aeb65bf8e46d307d', + '00000000-0000-0000-0000-000000000000', + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', ]); test('Validator isValidUuid with invalid UUIDs', function (string $uuid): void { @@ -353,22 +339,19 @@ })->with([ '', 'not-a-uuid', - '01966b97-8a07-70b2-aeb6-5bf8e46d307', // Too short - '01966b97-8a07-70b2-aeb6-5bf8e46d307d0', // Too long - '01966b97-8a07-70b2-aebz-5bf8e46d307d', // Invalid char - '01966b97-8a0770b2-aeb6-5bf8e46d307d', // Partially dashed - '01966b978a07-70b2aeb6-5bf8e46d307d', // Inconsistently dashed - "01966b97-8a07-70b2-aeb6-5bf8e46d307d\n", // Trailing newline + '01966b97-8a07-70b2-aeb6-5bf8e46d307', + '01966b97-8a07-70b2-aeb6-5bf8e46d307d0', + '01966b97-8a07-70b2-aebz-5bf8e46d307d', + '01966b97-8a0770b2-aeb6-5bf8e46d307d', + '01966b978a07-70b2aeb6-5bf8e46d307d', + "01966b97-8a07-70b2-aeb6-5bf8e46d307d\n", ]); -// ===== Edge Cases and Robustness Tests ===== - test('TypeID roundtrip with various prefixes and UUIDs', function (string $prefix, string $uuid): void { $typeId = TypeID::fromUuid($uuid, $prefix); expect($typeId->prefix)->toBe($prefix); expect($typeId->toUuid())->toBe($uuid); - // Roundtrip through string $typeIdString = $typeId->toString(); $parsedTypeId = TypeID::fromString($typeIdString); expect($parsedTypeId->prefix)->toBe($prefix); @@ -423,8 +406,6 @@ ['01jsnsr3fbe54rkjzfkta25nct', '01966b9c-0deb-7149-89cb-ef9e9422d59a'], ]); -// ===== New Coverage Tests ===== - test('fromUuid with uppercase UUID normalizes correctly', function (): void { $upper = '01966B97-8A07-70B2-AEB6-5BF8E46D307D'; $lower = '01966b97-8a07-70b2-aeb6-5bf8e46d307d'; @@ -446,9 +427,9 @@ test('Validator isValidSuffix with boundary suffix values', function (string $suffix, bool $expected): void { expect(Validator::isValidSuffix($suffix))->toBe($expected); })->with([ - ['7zzzzzzzzzzzzzzzzzzzzzzzzz', true], // max valid - ['7zzzzzzzzzzzzzzzzzzzzzzzzy', true], // just below max - ['8zzzzzzzzzzzzzzzzzzzzzzzzz', false], // overflow + 'max valid' => ['7zzzzzzzzzzzzzzzzzzzzzzzzz', true], + 'just below max' => ['7zzzzzzzzzzzzzzzzzzzzzzzzy', true], + 'overflow' => ['8zzzzzzzzzzzzzzzzzzzzzzzzz', false], ]); test('fromString roundtrip with max-length prefix', function (): void {