directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) { $this->logger->warning('Failed to create session directory.', [ 'directory' => $this->directory, 'error' => error_get_last()['message'] ?? 'unknown', ]); } if (!is_dir($this->directory) || !is_writable($this->directory)) { throw new RuntimeException(\sprintf('Session directory "%s" is not writable.', $this->directory)); } } public function exists(Uuid $id): bool { $path = $this->pathFor($id); if (!is_file($path)) { return false; } $mtime = @filemtime($path) ?: 0; return ($this->clock->now()->getTimestamp() - $mtime) <= $this->ttl; } public function read(Uuid $id): string|false { $path = $this->pathFor($id); if (!is_file($path)) { return false; } $mtime = @filemtime($path) ?: 0; if (($this->clock->now()->getTimestamp() - $mtime) > $this->ttl) { if (!@unlink($path) && is_file($path)) { $this->logger->warning('Failed to delete expired session file.', [ 'path' => $path, 'error' => error_get_last()['message'] ?? 'unknown', ]); } return false; } $data = @file_get_contents($path); if (false === $data) { $this->logger->warning('Failed to read session file.', [ 'path' => $path, 'error' => error_get_last()['message'] ?? 'unknown', ]); return false; } return $data; } public function write(Uuid $id, string $data): bool { $path = $this->pathFor($id); $tmp = $path.'.tmp'; if (false === @file_put_contents($tmp, $data, \LOCK_EX)) { $this->logger->warning('Failed to write session file.', [ 'path' => $tmp, 'error' => error_get_last()['message'] ?? 'unknown', ]); return false; } // Atomic move if (!@rename($tmp, $path)) { // Fallback if rename fails cross-device if (false === @copy($tmp, $path)) { $this->logger->warning('Failed to move session file into place.', [ 'path' => $path, 'error' => error_get_last()['message'] ?? 'unknown', ]); @unlink($tmp); return false; } @unlink($tmp); } @touch($path, $this->clock->now()->getTimestamp()); return true; } public function destroy(Uuid $id): bool { $path = $this->pathFor($id); if (is_file($path) && !@unlink($path) && is_file($path)) { $this->logger->warning('Failed to delete session file.', [ 'path' => $path, 'error' => error_get_last()['message'] ?? 'unknown', ]); } return true; } /** * Remove sessions older than the configured TTL. * Returns an array of deleted session IDs (UUID instances). */ public function gc(): array { $deleted = []; $now = $this->clock->now()->getTimestamp(); $dir = @opendir($this->directory); if (false === $dir) { $this->logger->warning('Failed to open session directory for garbage collection.', [ 'directory' => $this->directory, 'error' => error_get_last()['message'] ?? 'unknown', ]); return $deleted; } while (($entry = readdir($dir)) !== false) { // Skip dot entries if ('.' === $entry || '..' === $entry) { continue; } // Only delete files this store owns: sessions are named by their RFC 4122 UUID if (!Uuid::isValid($entry)) { continue; } $path = $this->directory.\DIRECTORY_SEPARATOR.$entry; if (!is_file($path)) { continue; } $mtime = @filemtime($path) ?: 0; if (($now - $mtime) > $this->ttl) { if (!@unlink($path) && is_file($path)) { $this->logger->warning('Failed to delete expired session file.', [ 'path' => $path, 'error' => error_get_last()['message'] ?? 'unknown', ]); continue; } $deleted[] = Uuid::fromString($entry); } } closedir($dir); return $deleted; } private function pathFor(Uuid $id): string { return $this->directory.\DIRECTORY_SEPARATOR.$id->toRfc4122(); } }