diff --git a/src/Session.php b/src/Session.php index de588c4..c6622bd 100644 --- a/src/Session.php +++ b/src/Session.php @@ -13,12 +13,14 @@ class Session implements SessionHandlerInterface { private string $savePath; + private string $prefix; private array $data = []; private bool $changed = false; private ?string $sessionId = null; private ?string $encryptionKey = null; private bool $autoCommit = true; private bool $testMode = false; + private bool $inRegenerate = false; /** * Constructor to initialize the session handler. @@ -34,11 +36,12 @@ class Session implements SessionHandlerInterface public function __construct(array $config = []) { $this->savePath = $config['save_path'] ?? sys_get_temp_dir() . '/flight_sessions'; + $this->prefix = $config['prefix'] ?? 'sess_'; $this->encryptionKey = $config['encryption_key'] ?? null; $this->autoCommit = $config['auto_commit'] ?? true; $startSession = $config['start_session'] ?? true; $this->testMode = $config['test_mode'] ?? false; - + // Set test session ID if provided if ($this->testMode === true && isset($config['test_session_id'])) { $this->sessionId = $config['test_session_id']; @@ -52,7 +55,7 @@ public function __construct(array $config = []) // Initialize session handler $this->initializeSession($startSession); } - + /** * Initialize the session handler and optionally start the session. * @@ -69,21 +72,19 @@ private function initializeSession(bool $startSession): void $this->read($this->sessionId); // Load session data for the test session ID return; // Skip actual session operations in test mode } - + // @codeCoverageIgnoreStart // Register the session handler only if no session is active yet if ($startSession === true && session_status() === PHP_SESSION_NONE) { // Make sure to register our handler before calling session_start session_set_save_handler($this, true); - + // Start the session with proper options session_start([ 'use_strict_mode' => true, 'use_cookies' => 1, 'use_only_cookies' => 1, - 'cookie_httponly' => 1, - 'sid_length' => 48, - 'sid_bits_per_character' => 6 + 'cookie_httponly' => 1 ]); $this->sessionId = session_id(); } elseif (session_status() === PHP_SESSION_ACTIVE) { @@ -98,7 +99,7 @@ private function initializeSession(bool $startSession): void // @codeCoverageIgnoreEnd } - + /** * Open a session. * @@ -131,6 +132,7 @@ public function close(): bool * @param string $id The session ID. * @return string The session data. */ + #[\ReturnTypeWillChange] public function read($id): string { $this->sessionId = $id; @@ -155,39 +157,27 @@ public function read($id): string // Handle plain data (no encryption) if ($prefix === 'P' && $this->encryptionKey === null) { - try { - $unserialized = unserialize($dataStr); - if ($unserialized !== false) { - $this->data = $unserialized; - return ''; // Return empty string to let PHP handle serialization - } - } catch (\Exception $e) { - // Silently handle unserialization errors + $unserialized = unserialize($dataStr); + if ($unserialized !== false) { + $this->data = $unserialized; + return ''; // Return empty string to let PHP handle serialization } - - $this->data = []; - return ''; } // Handle encrypted data if ($prefix === 'E' && $this->encryptionKey !== null) { - try { $iv = substr($dataStr, 0, 16); $encrypted = substr($dataStr, 16); $decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $this->encryptionKey, 0, $iv); - if ($decrypted !== false) { - $unserialized = unserialize($decrypted); - if ($unserialized !== false) { - $this->data = $unserialized; - return ''; - } + if ($decrypted !== false) { + $unserialized = unserialize($decrypted); + if ($unserialized !== false) { + $this->data = $unserialized; + return ''; } - } catch (\Exception $e) { - // Silently handle decryption or unserialization errors } } - // Fail fast: mismatch between prefix and encryption state or corruption $this->data = []; return ''; @@ -204,11 +194,11 @@ protected function encryptData(string $data) { $iv = openssl_random_pseudo_bytes(16); $encrypted = openssl_encrypt($data, 'AES-256-CBC', $this->encryptionKey, 0, $iv); - + if ($encrypted === false) { return false; // @codeCoverageIgnore } - + return 'E' . $iv . $encrypted; } @@ -220,7 +210,7 @@ public function write($id, $data): bool // When PHP calls this method, it passes serialized data // We ignore this parameter because we maintain our data internally // and handle serialization ourselves - + // Fail fast: no changes to write if ($this->changed === false && empty($this->data) === false) { return true; @@ -232,7 +222,7 @@ public function write($id, $data): bool // Handle encryption if key is provided if ($this->encryptionKey !== null) { $content = $this->encryptData($serialized); - + // Fail fast: encryption failed if ($content === false) { return false; @@ -253,12 +243,27 @@ public function write($id, $data): bool */ public function destroy($id): bool { + // If we're destroying the current session, clear the data + if ($id === $this->sessionId) { + $this->data = []; + $this->changed = true; + $this->autoCommit = false; // Disable auto-commit to prevent writing empty data + $this->commit(); + if ($this->testMode === false && $this->inRegenerate === false && session_status() === PHP_SESSION_ACTIVE) { + // Ensure session is closed + session_write_close(); // @codeCoverageIgnore + } + $this->sessionId = null; // Clear session ID + } + $file = $this->getSessionFile($id); - if (file_exists($file)) { - unlink($file); + if (file_exists($file) === true) { + $result = unlink($file); + if ($result === false) { + return false; // @codeCoverageIgnore + } } - $this->data = []; - $this->changed = true; + return true; } @@ -276,7 +281,7 @@ public function gc($maxLifetime) { $count = 0; $time = time(); - $pattern = $this->savePath . '/sess_*'; + $pattern = $this->savePath . '/' . $this->prefix . '*'; // Get session files; return 0 if glob fails or no files exist $files = glob($pattern); @@ -382,29 +387,34 @@ public function id(): ?string /** * Regenerates the session ID. * - * @param bool $deleteOld Whether to delete the old session data or not. + * @param bool $deleteOldFile Whether to delete the old session data or not. * @return self Returns the current instance for method chaining. */ - public function regenerate(bool $deleteOld = false): self + public function regenerate(bool $deleteOldFile = false): self { if ($this->sessionId) { + $oldId = $this->sessionId; + $oldData = $this->data; + $this->inRegenerate = true; + if ($this->testMode) { - // In test mode, simply generate a new ID without affecting PHP sessions - $oldId = $this->sessionId; + // In test mode, generate a new ID without affecting PHP sessions $this->sessionId = bin2hex(random_bytes(16)); - if ($deleteOld) { - $this->destroy($oldId); - } } else { // @codeCoverageIgnoreStart - session_regenerate_id($deleteOld); - $newId = session_id(); - if ($deleteOld) { - $this->destroy($this->sessionId); - } - $this->sessionId = $newId; + session_regenerate_id($deleteOldFile); + $this->sessionId = session_id(); // @codeCoverageIgnoreEnd } + $this->inRegenerate = false; + + // Save the current data with the new session ID first + if (empty($oldData) === false) { + $this->changed = true; + $this->data = $oldData; + $this->commit(); + } + $this->changed = true; } return $this; @@ -418,6 +428,6 @@ public function regenerate(bool $deleteOld = false): self */ private function getSessionFile(string $id): string { - return $this->savePath . '/sess_' . $id; + return $this->savePath . '/' . $this->prefix . $id; } } diff --git a/tests/SessionTest.php b/tests/SessionTest.php index d85bdb9..223dc01 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -1,7 +1,10 @@ deleteDirectory($file); - } + } elseif (is_dir($file) === true) { + $this->deleteDirectory($file); + } } rmdir($dir); } @@ -111,7 +114,7 @@ public function testReadWriteWithoutEncryption(): void 'auto_commit' => false, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); // No encryption, no auto-commit $session1->set('key', 'value'); $session1->commit(); @@ -123,7 +126,7 @@ public function testReadWriteWithoutEncryption(): void 'auto_commit' => false, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); $this->assertEquals('value', $session2->get('key')); } @@ -140,7 +143,7 @@ public function testReadWriteWithEncryption(): void 'auto_commit' => false, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); $session1->set('key', 'secret'); $session1->commit(); @@ -152,7 +155,7 @@ public function testReadWriteWithEncryption(): void 'auto_commit' => false, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); $this->assertEquals('secret', $session2->get('key')); } @@ -169,7 +172,7 @@ public function testAutoCommit(): void 'auto_commit' => true, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); $session1->set('key', 'value'); // No manual commit; simulate shutdown by calling commit() manually @@ -182,7 +185,7 @@ public function testAutoCommit(): void 'auto_commit' => false, 'start_session' => false, 'test_mode' => true, - 'test_session_id' => 'test_session_id1234' + 'test_session_id' => 'test_session_id1234' ]); $this->assertEquals('value', $session2->get('key')); } @@ -198,28 +201,28 @@ public function testGarbageCollection(): void 'test_mode' => true ]); $file = $this->tempDir . '/sess_testfile'; - + // Create an expired session file file_put_contents($file, 'Ptest'); // Simple content with 'P' prefix touch($file, time() - 3600); // Set file to 1 hour old - + $result = $session->gc(1800); // Max lifetime of 30 minutes $this->assertEquals(1, $result); // Expect 1 file deleted $this->assertFileDoesNotExist($file); } - public function testGarbageCollectionWithEmptyDirectory(): void - { - $session = new Session([ - 'save_path' => $this->tempDir, - 'start_session' => false, - 'test_mode' => true - ]); - - // Ensure the directory is empty - $result = $session->gc(1800); // Max lifetime of 30 minutes - $this->assertEquals(0, $result); // No files to delete - } + public function testGarbageCollectionWithEmptyDirectory(): void + { + $session = new Session([ + 'save_path' => $this->tempDir, + 'start_session' => false, + 'test_mode' => true + ]); + + // Ensure the directory is empty + $result = $session->gc(1800); // Max lifetime of 30 minutes + $this->assertEquals(0, $result); // No files to delete + } public function testGarbageCollectionWithNoExpiredFiles(): void { @@ -229,11 +232,11 @@ public function testGarbageCollectionWithNoExpiredFiles(): void 'test_mode' => true ]); $file = $this->tempDir . '/sess_testfile'; - + // Create a fresh session file file_put_contents($file, 'Ptest'); touch($file, time()); // File is not expired - + $result = $session->gc(1800); // Max lifetime of 30 minutes $this->assertEquals(0, $result); // No files deleted $this->assertFileExists($file); @@ -259,11 +262,11 @@ public function testGarbageCollectionWithNonIntegerMaxLifetime(): void 'test_mode' => true ]); $file = $this->tempDir . '/sess_testfile'; - + // Create an expired session file file_put_contents($file, 'Ptest'); touch($file, time() - 3600); // 1 hour old - + $result = $session->gc('1800'); // Pass a string instead of int $this->assertEquals(1, $result); // Should still work due to PHP's type juggling $this->assertFileDoesNotExist($file); @@ -300,19 +303,19 @@ public function testOpenAndClose(): void 'save_path' => $this->tempDir, 'test_mode' => true ]); - + // Using reflection to access these methods since they're normally called by PHP internally $reflector = new ReflectionClass($session); - + $openMethod = $reflector->getMethod('open'); $openMethod->setAccessible(true); $this->assertTrue($openMethod->invoke($session, $this->tempDir, 'PHPSESSID')); - + $closeMethod = $reflector->getMethod('close'); $closeMethod->setAccessible(true); $this->assertTrue($closeMethod->invoke($session)); } - + /** * Test the destroy method to ensure it removes session data. */ @@ -324,28 +327,28 @@ public function testDestroy(): void 'test_mode' => true, 'test_session_id' => $sessionId ]); - + // Create session data $session->set('key', 'value'); $session->commit(); - + // Verify the file exists $sessionFile = $this->tempDir . '/sess_' . $sessionId; $this->assertFileExists($sessionFile); - + // Use reflection to access destroy method $reflector = new ReflectionClass($session); $destroyMethod = $reflector->getMethod('destroy'); $destroyMethod->setAccessible(true); - + $result = $destroyMethod->invoke($session, $sessionId); $this->assertTrue($result); $this->assertFileDoesNotExist($sessionFile); - + // Check that internal data was cleared $this->assertNull($session->get('key')); } - + /** * Test reading from empty or invalid session files. */ @@ -353,31 +356,31 @@ public function testReadWithInvalidContent(): void { $sessionId = 'test_invalid_content'; $sessionFile = $this->tempDir . '/sess_' . $sessionId; - + // Create empty file file_put_contents($sessionFile, ''); - + $session = new Session([ 'save_path' => $this->tempDir, 'test_mode' => true, 'test_session_id' => $sessionId ]); - + // Data should be empty array when file is empty $this->assertNull($session->get('any_key')); - + // Try with invalid content too short for proper format file_put_contents($sessionFile, 'X'); - + $session2 = new Session([ 'save_path' => $this->tempDir, 'test_mode' => true, 'test_session_id' => $sessionId ]); - + $this->assertNull($session2->get('any_key')); } - + /** * Test mismatch between encryption state and file prefix. */ @@ -385,30 +388,30 @@ public function testReadWithPrefixMismatch(): void { $sessionId = 'test_prefix_mismatch'; $sessionFile = $this->tempDir . '/sess_' . $sessionId; - + // Create file with E prefix but we'll read without encryption key $data = serialize(['key' => 'value']); file_put_contents($sessionFile, 'E' . str_repeat('0', 16) . 'dummy_encrypted_data'); - + $session = new Session([ 'save_path' => $this->tempDir, 'test_mode' => true, 'test_session_id' => $sessionId ]); - + // Data should be empty when prefix doesn't match encryption state $this->assertNull($session->get('key')); - + // Now try P prefix with encryption file_put_contents($sessionFile, 'P' . serialize(['key' => 'value'])); - + $session2 = new Session([ 'save_path' => $this->tempDir, 'encryption_key' => $this->encryptionKey, 'test_mode' => true, 'test_session_id' => $sessionId ]); - + // Data should be empty when prefix doesn't match encryption state $this->assertNull($session2->get('key')); } @@ -420,7 +423,7 @@ public function testWriteWithEncryptionFailure(): void { // We need to mock openssl_encrypt to simulate failure $sessionId = 'test_encryption_failure'; - + // Create a partial mock of the Session class $session = $this->getMockBuilder(Session::class) ->setConstructorArgs([ @@ -433,70 +436,59 @@ public function testWriteWithEncryptionFailure(): void ]) ->onlyMethods(['encryptData']) ->getMock(); - + // Set up the mock to simulate encryption failure $session->method('encryptData')->willReturn(false); - + // Use reflection to make encryptData accessible and inject it $reflector = new ReflectionClass(Session::class); $writeMethod = $reflector->getMethod('write'); $writeMethod->setAccessible(true); - + // Set some data and mark as changed $session->set('key', 'value'); - + // Write should return false when encryption fails $result = $writeMethod->invoke($session, $sessionId, 'data'); $this->assertFalse($result); } - + /** * Test write method when no changes were made. */ public function testWriteWithNoChanges(): void { $sessionId = 'test_no_changes'; - + $session = new Session([ 'save_path' => $this->tempDir, 'test_mode' => true, 'test_session_id' => $sessionId ]); - - // Use reflection to access internal state and write method - $reflector = new ReflectionClass($session); - - $changedProperty = $reflector->getProperty('changed'); - $changedProperty->setAccessible(true); - $changedProperty->setValue($session, false); - - $writeMethod = $reflector->getMethod('write'); - $writeMethod->setAccessible(true); - - // Write should return true when nothing changed - $result = $writeMethod->invoke($session, $sessionId, 'data'); + $session->set('key', 'value'); + $session->commit(); // Commit to mark as changed + $result = $session->write($sessionId, ''); $this->assertTrue($result); } - public function testGetAll(): void - { - $session = new Session([ - 'save_path' => $this->tempDir, - 'encryption_key' => null, - 'auto_commit' => false, - 'start_session' => false, - 'test_mode' => true - ]); - - $session->set('key1', 'value1'); - $session->set('key2', 'value2'); + public function testGetAll(): void + { + $session = new Session([ + 'save_path' => $this->tempDir, + 'encryption_key' => null, + 'auto_commit' => false, + 'start_session' => false, + 'test_mode' => true + ]); - $expectedData = [ - 'key1' => 'value1', - 'key2' => 'value2' - ]; + $session->set('key1', 'value1'); + $session->set('key2', 'value2'); - $this->assertEquals($expectedData, $session->getAll()); - } + $expectedData = [ + 'key1' => 'value1', + 'key2' => 'value2' + ]; + $this->assertEquals($expectedData, $session->getAll()); + } }