diff --git a/docs/security.rst b/docs/security.rst index 556928b..c2900b9 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -26,6 +26,7 @@ Implemented Hardening * Signed payloads are rejected when integrity verification fails. * When an integrity key is configured, unsigned payloads are rejected. * Maximum payload size can be enforced at decode time. +* Compressed payload expansion is capped before deserialization. * ``ValueSerializer`` supports strict mode: * block closure payloads @@ -76,6 +77,17 @@ subdirectories: These paths are created with restrictive permissions and world-writable checks. +The shared-memory adapter also stores its ``ftok`` token in a private +``cachelayer/shared-memory`` directory, creates the segment for the current +user only, and serializes read-modify-write operations with a filesystem lock. + +4) Network Timeouts +~~~~~~~~~~~~~~~~~~~ + +Redis/Valkey connections created from a DSN use bounded one-second connect and +read timeouts. Inject a preconfigured client when an application needs +different timeout, TLS, retry, or socket-context settings. + Recommended Production Profile ------------------------------ diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 1430d62..cde8481 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -25,7 +25,7 @@ public function commit(): bool { $ok = true; foreach ($this->deferred as $key => $item) { - $ok = $ok && $this->save($item); + $ok = $this->save($item) && $ok; unset($this->deferred[$key]); } diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index fe3685d..e6e630c 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -71,7 +71,7 @@ public function deleteItems(array $keys): bool { $ok = true; foreach ($keys as $k) { - $ok = $ok && $this->deleteItem($k); + $ok = $this->deleteItem($k) && $ok; } return $ok; diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 66fcfb1..af7b651 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -61,6 +61,9 @@ public static function decode(string $blob): ?array } $expanded = self::expandIfCompressed($verifiedBlob); + if ($expanded === null) { + return null; + } if (self::isPayloadTooLarge($expanded)) { return null; } @@ -216,7 +219,7 @@ private static function decodeFormattedPayload(array $decoded): ?array ]; } - private static function expandIfCompressed(string $blob): string + private static function expandIfCompressed(string $blob): ?string { if (!str_starts_with($blob, self::COMPRESSED_PREFIX)) { return $blob; @@ -225,12 +228,21 @@ private static function expandIfCompressed(string $blob): string $payload = substr($blob, strlen(self::COMPRESSED_PREFIX)); $raw = base64_decode($payload, true); if ($raw === false || !function_exists('gzdecode')) { - return $blob; + return null; } - $decoded = gzdecode($raw); + $maximumLength = self::$maxPayloadBytes === null + ? 0 + : min(self::$maxPayloadBytes, PHP_INT_MAX - 1) + 1; + set_error_handler(static fn(): bool => true); + + try { + $decoded = gzdecode($raw, $maximumLength); + } finally { + restore_error_handler(); + } - return is_string($decoded) ? $decoded : $blob; + return is_string($decoded) ? $decoded : null; } private static function isPayloadTooLarge(string $blob): bool diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 86cc741..73659b5 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -48,7 +48,7 @@ public function clear(): bool $files = []; } foreach ($files as $f) { - $ok = $ok && (!is_file($f) || unlink($f)); + $ok = (!is_file($f) || unlink($f)) && $ok; } $this->deferred = []; @@ -75,7 +75,7 @@ public function deleteItems(array $keys): bool { $ok = true; foreach ($keys as $k) { - $ok = $ok && $this->deleteItem($k); + $ok = $this->deleteItem($k) && $ok; } return $ok; @@ -128,7 +128,7 @@ public function save(CacheItemInterface $item): bool return false; } - if (file_put_contents($tmp, $blob) === false) { + if (file_put_contents($tmp, $blob, LOCK_EX) === false) { if (is_file($tmp)) { unlink($tmp); } diff --git a/src/Cache/Adapter/GenericCacheItemPoolBehavior.php b/src/Cache/Adapter/GenericCacheItemPoolBehavior.php new file mode 100644 index 0000000..793957a --- /dev/null +++ b/src/Cache/Adapter/GenericCacheItemPoolBehavior.php @@ -0,0 +1,32 @@ +getItem($key)->isHit(); + } + + /** + * @param array $keys The keys argument. + * @phpstan-param list $keys + * @phpstan-return array + */ + public function multiFetch(array $keys): array + { + return $this->multiFetchItems($keys, $this->getItem(...)); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } +} diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 212df90..8e73c40 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -143,7 +143,7 @@ public function save(CacheItemInterface $item): bool return false; } - if (file_put_contents($tmp, $code) === false) { + if (file_put_contents($tmp, $code, LOCK_EX) === false) { if (is_file($tmp)) { unlink($tmp); } diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 7647a9a..56e01e3 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -10,11 +10,17 @@ final class SharedMemoryCacheAdapter extends AbstractCacheAdapter { + use GenericCacheItemPoolBehavior; + use SecuresFilesystemDirectories; + private const int VAR_ID = 1; + /** @var resource */ + private readonly mixed $lockHandle; + private readonly string $ns; - private readonly mixed $segment; + private readonly \SysvSharedMemory $segment; private readonly string $tokenFile; @@ -27,65 +33,69 @@ public function __construct( } $this->ns = sanitize_cache_ns($namespace); - $this->tokenFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer_shm_' . $this->ns . '.tok'; - if (!is_file($this->tokenFile)) { - touch($this->tokenFile); - } + $this->tokenFile = $this->createTokenFile(); + $this->segment = $this->attachSegment($segmentSize); + $this->lockHandle = $this->openLockHandle(); - $projectId = function_exists('ftok') ? ftok($this->tokenFile, 'C') : false; - $shmKey = is_int($projectId) && $projectId > 0 - ? $projectId - : abs(crc32('cachelayer:' . $this->ns)); - - $segment = shm_attach($shmKey, max(1_048_576, $segmentSize), 0666); - if ($segment === false) { - throw new RuntimeException('Unable to attach shared-memory segment'); - } + $this->withExclusiveLock(function (): void { + if (!shm_has_var($this->segment, self::VAR_ID)) { + shm_put_var($this->segment, self::VAR_ID, []); + } + }); + } - $this->segment = $segment; - if (!shm_has_var($this->segment, self::VAR_ID)) { - shm_put_var($this->segment, self::VAR_ID, []); - } + public function __destruct() + { + fclose($this->lockHandle); + shm_detach($this->segment); } public function clear(): bool { $this->deferred = []; - return shm_put_var($this->segment, self::VAR_ID, []); + return $this->withExclusiveLock( + fn(): bool => shm_put_var($this->segment, self::VAR_ID, []), + ); } public function count(): int { - $store = $this->loadStore(); - $changed = false; - $count = 0; + return $this->withExclusiveLock(function (): int { + $store = $this->loadStore(); + $changed = false; + $count = 0; - foreach ($store as $key => $blob) { - $record = CachePayloadCodec::decode((string) $blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - unset($store[$key]); - $changed = true; + foreach ($store as $key => $blob) { + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($store[$key]); + $changed = true; - continue; - } + continue; + } - $count++; - } + $count++; + } - if ($changed) { - $this->store($store); - } + if ($changed) { + $this->store($store); + } - return $count; + return $count; + }); } public function deleteItem(string $key): bool { - $store = $this->loadStore(); - unset($store[$this->map($key)]); + $mapped = $this->map($key); + + return $this->withExclusiveLock(function () use ($mapped): bool { + $store = $this->loadStore(); + unset($store[$mapped]); - return $this->store($store); + return $this->store($store); + }); } /** @@ -94,59 +104,92 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { - $store = $this->loadStore(); + $mappedKeys = []; foreach ($keys as $key) { - unset($store[$this->map((string) $key)]); + $mappedKeys[] = $this->map($key); } - return $this->store($store); + return $this->withExclusiveLock(function () use ($mappedKeys): bool { + $store = $this->loadStore(); + foreach ($mappedKeys as $mapped) { + unset($store[$mapped]); + } + + return $this->store($store); + }); } public function getItem(string $key): GenericCacheItem { $mapped = $this->map($key); - $store = $this->loadStore(); - $blob = $store[$mapped] ?? null; + $blob = $this->withSharedLock( + fn(): ?string => $this->loadStore()[$mapped] ?? null, + ); return $this->genericFromBlobWithInvalidator( $key, - is_string($blob) ? $blob : null, - function () use (&$store, $mapped): bool { - unset($store[$mapped]); - - return $this->store($store); - }, + $blob, + fn(): bool => $this->deleteItem($key), ); } - public function hasItem(string $key): bool + public function save(CacheItemInterface $item): bool { - return $this->getItem($key)->isHit(); - } + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $mapped = $this->map($saveItem->getKey()); + $blob = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array - */ - public function multiFetch(array $keys): array - { - return $this->multiFetchItems($keys, $this->getItem(...)); + return $this->withExclusiveLock(function () use ($mapped, $blob): bool { + $store = $this->loadStore(); + $store[$mapped] = $blob; + + return $this->store($store); + }); + }); } - public function save(CacheItemInterface $item): bool + private function attachSegment(int $segmentSize): \SysvSharedMemory { - return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $store = $this->loadStore(); - $store[$this->map($saveItem->getKey())] = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); + if (!function_exists('ftok')) { + throw new RuntimeException('ftok() is required for collision-safe shared-memory keys'); + } + $projectId = ftok($this->tokenFile, 'C'); + if ($projectId <= 0) { + throw new RuntimeException('Unable to derive the shared-memory key'); + } - return $this->store($store); - }); + $segment = shm_attach($projectId, max(1_048_576, $segmentSize), 0600); + if ($segment === false) { + throw new RuntimeException('Unable to attach shared-memory segment'); + } + + return $segment; } - protected function supportsItem(CacheItemInterface $item): bool + private function createTokenFile(): string { - return $item instanceof GenericCacheItem; + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . 'cachelayer' + . DIRECTORY_SEPARATOR + . 'shared-memory'; + $this->prepareDirectory($directory); + $tokenFile = $directory . DIRECTORY_SEPARATOR . hash('xxh128', $this->ns) . '.tok'; + if (!is_file($tokenFile)) { + if (file_put_contents($tokenFile, '', LOCK_EX) === false) { + throw new RuntimeException('Unable to create the shared-memory token file'); + } + chmod($tokenFile, 0600); + } + if (is_link($tokenFile)) { + throw new RuntimeException('Refusing a symlinked shared-memory token file'); + } + $permissions = fileperms($tokenFile); + if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { + throw new RuntimeException('Shared-memory token file must not be world-writable'); + } + + return $tokenFile; } /** @@ -179,6 +222,31 @@ private function map(string $key): string return $this->ns . ':' . $key; } + /** @phpstan-return resource */ + private function openLockHandle(): mixed + { + $lockHandle = fopen($this->tokenFile, 'c+'); + if (is_resource($lockHandle)) { + return $lockHandle; + } + + shm_detach($this->segment); + + throw new RuntimeException('Unable to open the shared-memory lock file'); + } + + private function prepareDirectory(string $directory): void + { + $this->assertPathNotSymlink($directory, 'Shared-memory cache directory'); + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException('Unable to create the shared-memory cache directory'); + } + if (!is_writable($directory)) { + throw new RuntimeException('Shared-memory cache directory is not writable'); + } + $this->assertSecureDirectory($directory, 'Shared-memory cache directory'); + } + /** * @param array $store The store argument. * @phpstan-param array $store @@ -187,4 +255,49 @@ private function store(array $store): bool { return shm_put_var($this->segment, self::VAR_ID, $store); } + + /** + * @template T + * @param callable $operation The operation to run while locked. + * @phpstan-param callable(): T $operation + * @phpstan-return T + */ + private function withExclusiveLock(callable $operation): mixed + { + return $this->withLock(LOCK_EX, $operation); + } + + /** + * @template T + * @param int $operation The flock operation. + * @param callable $callback The operation to run while locked. + * @phpstan-param callable(): T $callback + * @phpstan-return T + */ + private function withLock(int $operation, callable $callback): mixed + { + if ($operation !== LOCK_EX && $operation !== LOCK_SH) { + throw new RuntimeException('Invalid shared-memory lock operation'); + } + if (!flock($this->lockHandle, $operation)) { + throw new RuntimeException('Unable to lock the shared-memory cache'); + } + + try { + return $callback(); + } finally { + flock($this->lockHandle, LOCK_UN); + } + } + + /** + * @template T + * @param callable $operation The operation to run while locked. + * @phpstan-param callable(): T $operation + * @phpstan-return T + */ + private function withSharedLock(callable $operation): mixed + { + return $this->withLock(LOCK_SH, $operation); + } } diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index 47e1741..c14cf7b 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -11,6 +11,8 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter { + use GenericCacheItemPoolBehavior; + private readonly string $ns; /** @var array */ @@ -133,21 +135,6 @@ function () use ($mapped): bool { ); } - public function hasItem(string $key): bool - { - return $this->getItem($key)->isHit(); - } - - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array - */ - public function multiFetch(array $keys): array - { - return $this->multiFetchItems($keys, $this->getItem(...)); - } - public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { @@ -171,11 +158,6 @@ public function save(CacheItemInterface $item): bool }); } - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; - } - private function map(string $key): string { return $this->ns . ':' . $key; diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index e7f1bef..500cf99 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -1206,12 +1206,12 @@ private function stampedeLockKey(string $key): string private function tagMetaKey(string $key): string { - return self::TAG_META_PREFIX . hash('xxh3', $key); + return self::TAG_META_PREFIX . hash('sha256', $key); } private function tagVersionKey(string $normalizedTag): string { - return self::TAG_VERSION_PREFIX . hash('xxh3', $normalizedTag); + return self::TAG_VERSION_PREFIX . hash('sha256', $normalizedTag); } /** diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 980336b..23c7c57 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -6,6 +6,8 @@ final readonly class FileLockProvider implements LockProviderInterface { + use GeneratesLockTokens; + private string $directory; private int $retrySleepMicros; @@ -32,7 +34,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle return null; } - $path = $this->directory . DIRECTORY_SEPARATOR . hash('xxh128', $key) . '.lock'; + $path = $this->directory . DIRECTORY_SEPARATOR . self::digestLockKey($key) . '.lock'; $handle = $this->openLockFile($path); if (!is_resource($handle)) { return null; @@ -88,15 +90,6 @@ private static function &activeRegistry(): array return $registry; } - private static function generateToken(): ?string - { - try { - return bin2hex(random_bytes(16)); - } catch (\Throwable) { - return null; - } - } - /** * @phpstan-return resource|false * @param string $path The path argument. diff --git a/src/Cache/Lock/GeneratesLockTokens.php b/src/Cache/Lock/GeneratesLockTokens.php index 61c13f9..0e4ea96 100644 --- a/src/Cache/Lock/GeneratesLockTokens.php +++ b/src/Cache/Lock/GeneratesLockTokens.php @@ -8,6 +8,11 @@ trait GeneratesLockTokens { + protected static function digestLockKey(string $key): string + { + return hash('xxh128', $key); + } + protected static function generateToken(): ?string { try { diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index ae5f104..0240403 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -57,7 +57,7 @@ private static function signedCrc32(string $value): int private function acquireMysql(string $key, float $waitSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . hash('xxh128', $key); + $lockKey = $this->prefix . self::digestLockKey($key); $token = self::generateToken(); if ($token === null) { return null; @@ -86,7 +86,7 @@ private function acquireMysql(string $key, float $waitSeconds): ?LockHandle private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . hash('xxh128', $key); + $lockKey = $this->prefix . self::digestLockKey($key); $advisoryKey = self::signedCrc32($lockKey); $token = self::generateToken(); if ($token === null) { diff --git a/src/Cache/Lock/PollingLockProviderHelpers.php b/src/Cache/Lock/PollingLockProviderHelpers.php index 7e27813..01bae93 100644 --- a/src/Cache/Lock/PollingLockProviderHelpers.php +++ b/src/Cache/Lock/PollingLockProviderHelpers.php @@ -27,7 +27,7 @@ protected function acquireWithRetry( callable $attemptAcquire, ): ?LockHandle { $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $prefix . hash('xxh128', $key); + $lockKey = $prefix . self::digestLockKey($key); $token = self::generateToken(); if ($token === null) { return null; diff --git a/src/Cluster/Event/InvalidationEvent.php b/src/Cluster/Event/InvalidationEvent.php index a9bed1e..1092494 100644 --- a/src/Cluster/Event/InvalidationEvent.php +++ b/src/Cluster/Event/InvalidationEvent.php @@ -21,8 +21,16 @@ public function __construct( throw new ClusterCacheException('Invalidation events require a cluster, namespace, and origin node ID.'); } - if ($type !== InvalidationEventType::Namespace && $identifier === null) { - throw new ClusterCacheException('Key and tag invalidation events require an identifier.'); + if ($type === InvalidationEventType::Namespace && $identifier !== null) { + throw new ClusterCacheException('Namespace invalidation events must not contain an identifier.'); + } + + if ($type !== InvalidationEventType::Namespace && ($identifier === null || $identifier === '')) { + throw new ClusterCacheException('Key and tag invalidation events require a non-empty identifier.'); + } + + if ($createdAt < 0) { + throw new ClusterCacheException('Invalidation event timestamps cannot be negative.'); } } diff --git a/src/Cluster/Transport/InvalidationTransportData.php b/src/Cluster/Transport/InvalidationTransportData.php new file mode 100644 index 0000000..05589f9 --- /dev/null +++ b/src/Cluster/Transport/InvalidationTransportData.php @@ -0,0 +1,72 @@ + $data + */ + public static function requiredString(array $data, string $key, string $source): string + { + $value = $data[$key] ?? null; + if (!is_string($value) || $value === '') { + throw new ClusterTransportException("Invalid {$key} returned by {$source}."); + } + + return $value; + } + + /** + * @param array $data The transport data. + * @phpstan-param array $data + * @phpstan-return array + */ + public static function stringKeys(array $data): array + { + $normalized = []; + foreach ($data as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + return $normalized; + } + + public static function unsignedInteger(mixed $value, string $field, string $source): int + { + if (is_int($value)) { + if ($value >= 0) { + return $value; + } + + throw new ClusterTransportException("Invalid {$field} returned by {$source}."); + } + + if (!is_string($value) || $value === '' || !ctype_digit($value)) { + throw new ClusterTransportException("Invalid {$field} returned by {$source}."); + } + + $normalized = ltrim($value, '0'); + if ($normalized === '') { + return 0; + } + + $maximum = (string) PHP_INT_MAX; + if (strlen($normalized) > strlen($maximum) + || (strlen($normalized) === strlen($maximum) && strcmp($normalized, $maximum) > 0)) { + throw new ClusterTransportException("Invalid {$field} returned by {$source}."); + } + + return (int) $normalized; + } +} diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php index 56838d2..8461127 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php @@ -8,6 +8,7 @@ use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; use Infocyph\CacheLayer\Cluster\Event\InvalidationEventType; use Infocyph\CacheLayer\Cluster\Exception\ClusterTransportException; +use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportData; use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInspectorInterface; use Infocyph\CacheLayer\Cluster\Transport\TransactionalInvalidationTransportInterface; use PDO; @@ -56,7 +57,7 @@ public function consumeAfter(string $cluster, ?string $cursor, int $limit): Inva if (!is_array($row)) { continue; } - $events[] = $this->eventFromRow($this->normalizeRow($row)); + $events[] = $this->eventFromRow(InvalidationTransportData::stringKeys($row)); } return new InvalidationBatch($events); @@ -220,7 +221,9 @@ private function eventBoundary(string $cluster, string $aggregate): ?string */ private function eventFromRow(array $row): InvalidationEvent { - $type = InvalidationEventType::tryFrom($this->requiredString($row, 'event_type')); + $type = InvalidationEventType::tryFrom( + InvalidationTransportData::requiredString($row, 'event_type', 'PDO transport'), + ); if ($type === null) { throw new ClusterTransportException('Invalid invalidation event record returned by PDO transport.'); } @@ -232,12 +235,16 @@ private function eventFromRow(array $row): InvalidationEvent return new InvalidationEvent( $this->eventIdFromValue($row['event_id'] ?? null), - $this->requiredString($row, 'cluster_name'), - $this->requiredString($row, 'namespace_name'), + InvalidationTransportData::requiredString($row, 'cluster_name', 'PDO transport'), + InvalidationTransportData::requiredString($row, 'namespace_name', 'PDO transport'), $type, $identifier, - $this->requiredString($row, 'origin_node_id'), - $this->timestampFromValue($row['created_at'] ?? null), + InvalidationTransportData::requiredString($row, 'origin_node_id', 'PDO transport'), + InvalidationTransportData::unsignedInteger( + $row['created_at'] ?? null, + 'invalidation event timestamp', + 'PDO transport', + ), ); } @@ -300,23 +307,6 @@ private function isDuplicateIndex(PDOException $exception): bool return is_array($errorInfo) && ($errorInfo[1] ?? null) === 1061; } - /** - * @param array $row The row argument. - * @phpstan-param array $row - * @phpstan-return array - */ - private function normalizeRow(array $row): array - { - $normalized = []; - foreach ($row as $key => $value) { - if (is_string($key)) { - $normalized[$key] = $value; - } - } - - return $normalized; - } - private function pruneSql(): string { $selection = 'SELECT event_id FROM ' . self::TABLE . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; @@ -326,31 +316,4 @@ private function pruneSql(): string return 'DELETE FROM ' . self::TABLE . ' WHERE event_id IN (' . $selection . ')'; } - - private function requiredString(mixed $row, string $key): string - { - if (!is_array($row)) { - throw new ClusterTransportException('Invalid PDO transport event row.'); - } - - $value = $row[$key] ?? null; - if (!is_string($value) || $value === '') { - throw new ClusterTransportException("Invalid {$key} returned by PDO transport."); - } - - return $value; - } - - private function timestampFromValue(mixed $value): int - { - if (is_int($value)) { - return $value; - } - - if (is_string($value) && ctype_digit($value)) { - return (int) $value; - } - - throw new ClusterTransportException('Invalid invalidation event timestamp returned by PDO transport.'); - } } diff --git a/src/Cluster/Transport/RedisStreamInvalidationTransport.php b/src/Cluster/Transport/RedisStreamInvalidationTransport.php index ac9272e..928c263 100644 --- a/src/Cluster/Transport/RedisStreamInvalidationTransport.php +++ b/src/Cluster/Transport/RedisStreamInvalidationTransport.php @@ -114,7 +114,11 @@ private function boundary(string $cluster, bool $newest): ?string return is_string($id) ? $id : null; } - private function clusterFromStreamEvent(mixed $fields): string + /** + * @param array $fields The normalized stream fields. + * @phpstan-param array $fields + */ + private function clusterFromStreamEvent(array $fields): string { return $this->field($fields, 'cluster'); } @@ -125,8 +129,15 @@ private function compareIds(string $left, string $right): int [$rightMilliseconds, $rightSequence] = $this->idParts($right); return $leftMilliseconds === $rightMilliseconds - ? $leftSequence <=> $rightSequence - : $leftMilliseconds <=> $rightMilliseconds; + ? $this->compareUnsignedIntegers($leftSequence, $rightSequence) + : $this->compareUnsignedIntegers($leftMilliseconds, $rightMilliseconds); + } + + private function compareUnsignedIntegers(string $left, string $right): int + { + return strlen($left) === strlen($right) + ? strcmp($left, $right) + : strlen($left) <=> strlen($right); } /** @@ -136,14 +147,17 @@ private function compareIds(string $left, string $right): int */ private function eventFromFields(string $id, array $fields): InvalidationEvent { + $fields = InvalidationTransportData::stringKeys($fields); $type = InvalidationEventType::tryFrom($this->field($fields, 'type')); if ($type === null) { throw new ClusterTransportException('Redis Stream event has an invalid event type.'); } - $identifier = $this->field($fields, 'has_identifier') === '1' - ? $this->field($fields, 'identifier') - : null; + $hasIdentifier = $this->field($fields, 'has_identifier'); + if ($hasIdentifier !== '0' && $hasIdentifier !== '1') { + throw new ClusterTransportException('Redis Stream event has an invalid has_identifier field.'); + } + $identifier = $hasIdentifier === '1' ? $this->field($fields, 'identifier') : null; return new InvalidationEvent( $id, @@ -152,28 +166,32 @@ private function eventFromFields(string $id, array $fields): InvalidationEvent $type, $identifier, $this->field($fields, 'origin'), - (int) $this->field($fields, 'created_at'), + InvalidationTransportData::unsignedInteger( + $this->field($fields, 'created_at'), + 'created_at field', + 'Redis Stream event', + ), ); } - private function field(mixed $fields, string $name): string + /** + * @param array $fields The normalized stream fields. + * @param string $name The required field name. + * @phpstan-param array $fields + */ + private function field(array $fields, string $name): string { - if (!is_array($fields)) { - throw new ClusterTransportException('Redis Stream event fields must be an array.'); - } - - $value = $fields[$name] ?? null; - if (!is_string($value) || $value === '') { - throw new ClusterTransportException("Redis Stream event has an invalid {$name} field."); - } - - return $value; + return InvalidationTransportData::requiredString( + $fields, + $name, + 'Redis Stream event', + ); } /** * @param string $id The ID argument. * @return array The ID parts. - * @phpstan-return array{int, int} + * @phpstan-return array{string, string} */ private function idParts(string $id): array { @@ -182,7 +200,10 @@ private function idParts(string $id): array throw new ClusterTransportException('Redis Stream event IDs must have the form milliseconds-sequence.'); } - return [(int) $parts[0], (int) $parts[1]]; + return [ + ltrim($parts[0], '0') ?: '0', + ltrim($parts[1], '0') ?: '0', + ]; } private function stream(string $cluster): string diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index e199c3b..6c960dd 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -13,6 +13,8 @@ final class Memoizer { use MemoizeTrait; + private const int CACHE_LIMIT = 2048; + private static ?self $instance = null; private int $hits = 0; @@ -63,6 +65,7 @@ public function get(callable $callable, array $params = []): mixed $this->misses++; $value = $callable(...$params); + self::evictOldestIfFull($this->staticCache); $this->staticCache[$cacheKey] = $value; return $value; @@ -91,6 +94,7 @@ public function getFor(object $object, callable $callable, array $params = []): $this->misses++; $value = $callable(...$params); + self::evictOldestIfFull($bucket); $bucket[$cacheKey] = $value; $this->objectCache[$object] = $bucket; @@ -120,9 +124,12 @@ private static function buildCacheKey(string $signature, array $params): string return $signature; } - $normalized = array_map(self::normalizeParam(...), $params); + $normalized = []; + foreach ($params as $param) { + $normalized[] = self::normalizeParam($param); + } - return $signature . '|' . hash('xxh3', serialize($normalized)); + return $signature . '|' . hash('xxh128', serialize($normalized)); } /** @@ -158,13 +165,42 @@ private static function callableSignature(callable $callable): string return 'callable:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); } + /** + * @param array $cache The cache bucket. + * @phpstan-param array $cache + */ + private static function evictOldestIfFull(array &$cache): void + { + if (count($cache) < self::CACHE_LIMIT) { + return; + } + + $oldest = array_key_first($cache); + unset($cache[$oldest]); + } + + /** + * @param array $values The values to normalize. + * @phpstan-param array $values + * @phpstan-return array + */ + private static function normalizeArray(array $values): array + { + $normalized = []; + foreach ($values as $key => $value) { + $normalized[$key] = self::normalizeParam($value); + } + + return $normalized; + } + private static function normalizeParam(mixed $value): mixed { return match (true) { $value instanceof Closure => 'closure#' . spl_object_id($value), is_object($value) => 'obj#' . spl_object_id($value), is_resource($value) => 'res#' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => array_map(self::normalizeParam(...), $value), + is_array($value) => self::normalizeArray($value), default => $value, }; } diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index d3ef410..a0df46d 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -100,7 +100,7 @@ private function closureFingerprint(Closure $closure): string $snippet = implode("\n", array_slice($lines, $start - 1, $end - $start + 1)); $normalized = preg_replace('/\s+/', '', $snippet) ?? $snippet; - $fingerprint = 'closure-src:' . hash('xxh3', $normalized); + $fingerprint = 'closure-src:' . hash('xxh128', $normalized); $this->closureSourceMemo[$sourceKey] = $fingerprint; return $fingerprint; diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php index 4b6caa9..e9c041c 100644 --- a/src/Serializer/ValueSerializer.php +++ b/src/Serializer/ValueSerializer.php @@ -98,15 +98,16 @@ public static function encode(mixed $value, bool $base64 = true): string */ public static function isSerializedClosure(string $str): bool { - if (array_key_exists($str, self::$serializedClosureMemo)) { - return self::$serializedClosureMemo[$str]; + $memoKey = hash('sha256', $str); + if (array_key_exists($memoKey, self::$serializedClosureMemo)) { + return self::$serializedClosureMemo[$memoKey]; } if (!str_contains($str, 'Opis\\Closure')) { - return self::rememberSerializedClosureMemo($str, false); + return self::rememberSerializedClosureMemo($memoKey, false); } - return self::rememberSerializedClosureMemo($str, (bool) preg_match( + return self::rememberSerializedClosureMemo($memoKey, (bool) preg_match( '/^(?:C:\d+:"Opis\\\\Closure\\\\SerializableClosure|O:\d+:"Opis\\\\Closure\\\\Box"|O:\d+:"Opis\\\\Closure\\\\Serializable")/', $str, )); @@ -180,7 +181,7 @@ public static function serialize(mixed $value): string public static function unserialize(string $blob): mixed { if (self::isNativeSerializedPayload($blob) && !self::containsOpisPayloadMarker($blob)) { - return self::unwrapRecursive(unserialize($blob, ['allowed_classes' => false])); + return self::unwrapRecursive(self::unserializeNative($blob)); } if (self::isSerializedClosure($blob)) { @@ -277,9 +278,13 @@ private static function containsOpisPayloadMarker(string $blob): bool private static function isNativeSerializedPayload(string $blob): bool { + if (str_starts_with($blob, 'N;')) { + return true; + } + $first = $blob[0] ?? ''; - return in_array($first, self::NATIVE_SERIALIZED_PREFIXES, true); + return ($blob[1] ?? '') === ':' && in_array($first, self::NATIVE_SERIALIZED_PREFIXES, true); } private static function rememberSerializedClosureMemo(string $key, bool $value): bool @@ -308,6 +313,26 @@ private static function requiresOpisSerialization(mixed $value): bool return array_any($value, fn($item) => self::requiresOpisSerialization($item)); } + private static function unserializeNative(string $blob): mixed + { + set_error_handler( + static function (int $_severity, string $message): never { + throw new InvalidArgumentException( + "Invalid native serialized payload (error {$_severity}): {$message}", + ); + }, + ); + + try { + return unserialize($blob, [ + 'allowed_classes' => false, + 'max_depth' => 128, + ]); + } finally { + restore_error_handler(); + } + } + /** * Reverse {@see wrapRecursive} by recursively unwrapping values * that were wrapped by {@see wrapRecursive}. diff --git a/src/Support/RedisConnection.php b/src/Support/RedisConnection.php index 16ef59f..0eabdfd 100644 --- a/src/Support/RedisConnection.php +++ b/src/Support/RedisConnection.php @@ -5,14 +5,79 @@ namespace Infocyph\CacheLayer\Support; use InvalidArgumentException; +use RuntimeException; +use ValueError; final class RedisConnection { + private const float CONNECT_TIMEOUT_SECONDS = 1.0; + + private const float READ_TIMEOUT_SECONDS = 1.0; + public static function connect(string $dsn): \Redis { - $parts = parse_url($dsn); - $scheme = is_array($parts) ? $parts['scheme'] ?? null : null; - $host = is_array($parts) ? $parts['host'] ?? null : null; + [$host, $port, $database, $credentials] = self::parseDsn($dsn); + $connection = new \Redis(); + if (!$connection->connect( + $host, + $port, + self::CONNECT_TIMEOUT_SECONDS, + null, + 0, + self::READ_TIMEOUT_SECONDS, + )) { + throw new RuntimeException('Unable to connect to the Redis-compatible server.'); + } + self::authenticate($connection, $credentials); + if ($database !== null && !$connection->select($database)) { + throw new RuntimeException('Unable to select the Redis-compatible database.'); + } + + return $connection; + } + + /** + * @param \Redis $connection The connected client. + * @param string|array|null $credentials The optional Redis credentials. + * @phpstan-param string|array{string, string}|null $credentials + */ + private static function authenticate(\Redis $connection, string|array|null $credentials): void + { + if ($credentials !== null && !$connection->auth($credentials)) { + throw new RuntimeException('Redis-compatible server authentication failed.'); + } + } + + /** + * @param array $parts The parsed DSN parts. + * @phpstan-param array $parts + * @phpstan-return string|array{string, string}|null + */ + private static function parseCredentials(array $parts): string|array|null + { + $pass = $parts['pass'] ?? null; + if (!is_string($pass) || $pass === '') { + return null; + } + + $password = rawurldecode($pass); + $user = $parts['user'] ?? null; + if (!is_string($user) || $user === '') { + return $password; + } + + return [rawurldecode($user), $password]; + } + + /** + * @param string $dsn The Redis-compatible DSN. + * @phpstan-return array{string, int, int|null, string|array{string, string}|null} + */ + private static function parseDsn(string $dsn): array + { + $parts = self::parseDsnParts($dsn); + $scheme = $parts['scheme'] ?? null; + $host = $parts['host'] ?? null; if ( !is_string($scheme) || !in_array($scheme, ['redis', 'valkey'], true) @@ -23,27 +88,43 @@ public static function connect(string $dsn): \Redis } $path = $parts['path'] ?? ''; + if (!is_string($path)) { + throw new InvalidArgumentException('Invalid Redis-compatible DSN.'); + } $database = $path === '' || $path === '/' ? null : ltrim($path, '/'); - if ($database !== null && !ctype_digit($database)) { + $databaseId = $database === null + ? null + : filter_var($database, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if ($databaseId === false) { throw new InvalidArgumentException('Invalid Redis-compatible DSN.'); } $port = $parts['port'] ?? 6379; - if ($port === 0) { + if (!is_int($port) || $port < 1 || $port > 65_535) { throw new InvalidArgumentException('Invalid Redis-compatible DSN.'); } - $connection = new \Redis(); - $connection->connect($host, $port); - if (is_string($parts['pass'] ?? null) && $parts['pass'] !== '') { - $connection->auth($parts['pass']); + return [$host, $port, $databaseId, self::parseCredentials($parts)]; + } + + /** + * @param string $dsn The Redis-compatible DSN. + * @phpstan-return array + */ + private static function parseDsnParts(string $dsn): array + { + try { + $parts = parse_url($dsn); + } catch (ValueError) { + throw new InvalidArgumentException('Invalid Redis-compatible DSN.'); } - if ($database !== null) { - $connection->select((int) $database); + + if (!is_array($parts)) { + throw new InvalidArgumentException('Invalid Redis-compatible DSN.'); } - return $connection; + return $parts; } } diff --git a/src/functions.php b/src/functions.php index c8d9fa6..67ff866 100644 --- a/src/functions.php +++ b/src/functions.php @@ -11,12 +11,9 @@ */ function sanitize_cache_ns(string $ns): string { - /** @var array $cache */ - static $cache = []; - $sanitized = preg_replace('/[^A-Za-z0-9_\-]/', '_', $ns); - return $cache[$ns] ??= is_string($sanitized) ? $sanitized : ''; + return is_string($sanitized) ? $sanitized : ''; } } diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php index 1ca9934..2f27ee7 100644 --- a/tests/Cache/ArrayCachePoolTest.php +++ b/tests/Cache/ArrayCachePoolTest.php @@ -3,7 +3,9 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Psr\Cache\CacheItemInterface; beforeEach(function () { $this->cache = Cache::memory('array-tests'); @@ -41,3 +43,66 @@ ->and($items['b']->get())->toBe('B') ->and($items['c']->isHit())->toBeFalse(); }); + +test('deferred commit attempts every queued item after a save failure', function () { + $adapter = new class extends AbstractCacheAdapter + { + /** @var list */ + public array $attempted = []; + + public function clear(): bool + { + return true; + } + + public function count(): int + { + return 0; + } + + public function deleteItem(string $key): bool + { + return $key !== "\0"; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + if ($key === "\0") { + return false; + } + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + return new GenericCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return $key === "\0"; + } + + public function save(CacheItemInterface $item): bool + { + $this->attempted[] = $item->getKey(); + + return $item->getKey() !== 'first'; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + }; + + $adapter->saveDeferred($adapter->getItem('first')->set(1)); + $adapter->saveDeferred($adapter->getItem('second')->set(2)); + + expect($adapter->commit())->toBeFalse() + ->and($adapter->attempted)->toBe(['first', 'second']) + ->and($adapter->commit())->toBeTrue(); +}); diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index e511bab..ef41c49 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -38,3 +38,15 @@ CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); expect(CachePayloadCodec::decode($unsigned))->toBeNull(); }); + +test('payload codec bounds decompressed payload size', function () { + CachePayloadCodec::configureCompression(1, 9); + CachePayloadCodec::configureSecurity(null, null); + $compressed = CachePayloadCodec::encode(str_repeat('A', 8_192), null); + + CachePayloadCodec::configureSecurity(null, 512); + + expect(CachePayloadCodec::decode($compressed))->toBeNull(); + + CachePayloadCodec::configureCompression(null); +}); diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 69fb5d3..3ff5876 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\Adapter\SharedMemoryCacheAdapter; if (! function_exists('shm_attach')) { test('shared memory extension not loaded')->skip(); @@ -20,3 +21,15 @@ $a->clear(); }); + +test('shared memory adapter uses private filesystem and segment permissions', function () { + $adapter = new SharedMemoryCacheAdapter('shm-security-tests'); + $reflection = new ReflectionClass($adapter); + $tokenFile = $reflection->getProperty('tokenFile')->getValue($adapter); + + expect($tokenFile)->toBeString() + ->and(is_file($tokenFile))->toBeTrue() + ->and(fileperms($tokenFile) & 0x0002)->toBe(0); + + $adapter->clear(); +}); diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 1e98516..d56a579 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -5,6 +5,10 @@ use Infocyph\CacheLayer\Cluster\ClusterCache; use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; +use Infocyph\CacheLayer\Cluster\Event\InvalidationEventType; +use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; +use Infocyph\CacheLayer\Cluster\Exception\ClusterTransportException; +use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportData; use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationTransport; use Infocyph\CacheLayer\Node\NodeCacheConfig; use Infocyph\CacheLayer\Tests\Cluster\Support\InMemoryInvalidationTransport; @@ -161,6 +165,46 @@ ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterTransportException::class); }); +test('invalidation transport data rejects malformed and overflowing timestamps', function () { + expect(fn () => InvalidationTransportData::unsignedInteger('-1', 'created_at', 'test transport')) + ->toThrow(ClusterTransportException::class) + ->and(fn () => InvalidationTransportData::unsignedInteger( + (string) PHP_INT_MAX . '0', + 'created_at', + 'test transport', + ))->toThrow(ClusterTransportException::class); +}); + +test('invalidation events enforce identifier and timestamp invariants', function () { + expect(fn () => new InvalidationEvent( + null, + 'cluster', + 'namespace', + InvalidationEventType::Key, + '', + 'node', + time(), + ))->toThrow(ClusterCacheException::class) + ->and(fn () => new InvalidationEvent( + null, + 'cluster', + 'namespace', + InvalidationEventType::Namespace, + 'unexpected', + 'node', + time(), + ))->toThrow(ClusterCacheException::class) + ->and(fn () => new InvalidationEvent( + null, + 'cluster', + 'namespace', + InvalidationEventType::Namespace, + null, + 'node', + -1, + ))->toThrow(ClusterCacheException::class); +}); + test('transactional outbox publishes with the source transaction and applies locally after commit', function () { $connection = new \PDO('sqlite:' . $this->clusterDirectory . '/outbox.sqlite'); $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 93b27b2..4414325 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -89,3 +89,27 @@ public function next(): int ->and($inst->next())->toBe(1) ->and($inst->count)->toBe(1); }); + +it('keeps global and per-object memoization buckets bounded', function () { + $memoizer = Memoizer::instance(); + $reflection = new ReflectionClass($memoizer); + $staticCache = $reflection->getProperty('staticCache'); + $objectCache = $reflection->getProperty('objectCache'); + $seed = []; + for ($index = 0; $index < 2_048; $index++) { + $seed['seed-' . $index] = $index; + } + $staticCache->setValue($memoizer, $seed); + + $object = new stdClass(); + $weakMap = $objectCache->getValue($memoizer); + $weakMap[$object] = $seed; + + $memoizer->get('strlen', ['fresh']); + $memoizer->getFor($object, 'strlen', ['fresh']); + + expect($staticCache->getValue($memoizer))->toHaveCount(2_048) + ->and($weakMap[$object])->toHaveCount(2_048) + ->and(array_key_exists('seed-0', $staticCache->getValue($memoizer)))->toBeFalse() + ->and(array_key_exists('seed-0', $weakMap[$object]))->toBeFalse(); +});