diff --git a/tests/_support/Session/FaultyStream.php b/tests/_support/Session/FaultyStream.php new file mode 100644 index 000000000000..8f7bea30411e --- /dev/null +++ b/tests/_support/Session/FaultyStream.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Session; + +/** + * A stream wrapper that can be configured to fail on lock, read, or write. + * + * Used by {@see \CodeIgniter\Session\Handlers\FileHandlerTest} to exercise the + * failure branches of {@see \CodeIgniter\Session\Handlers\FileHandler::read()} + * and {@see \CodeIgniter\Session\Handlers\FileHandler::write()}. + * + * @internal + */ +final class FaultyStream +{ + public $context; + private static bool $failLock = false; + private static bool $failRead = false; + private static bool $failWrite = false; + private $stream; + + public static function failLock(bool $fail): void + { + self::$failLock = $fail; + } + + public static function failRead(bool $fail): void + { + self::$failRead = $fail; + } + + public static function failWrite(bool $fail): void + { + self::$failWrite = $fail; + } + + public static function reset(): void + { + self::$failLock = false; + self::$failRead = false; + self::$failWrite = false; + } + + public function stream_open(): bool + { + $this->stream = fopen('php://temp', 'c+b'); + + return $this->stream !== false; + } + + public function stream_read(int $count): false|string + { + return self::$failRead ? false : fread($this->stream, $count); + } + + public function stream_write(string $data): false|int + { + return self::$failWrite ? false : fwrite($this->stream, $data); + } + + public function stream_lock(): bool + { + return ! self::$failLock; + } + + public function stream_close(): void + { + fclose($this->stream); + } + + public function stream_eof(): bool + { + return feof($this->stream); + } + + public function stream_tell(): int + { + return ftell($this->stream); + } + + public function stream_seek(int $offset, int $whence): bool + { + return fseek($this->stream, $offset, $whence) === 0; + } + + public function stream_truncate(int $newSize): bool + { + return ftruncate($this->stream, $newSize); + } + + public function stream_stat(): array + { + return fstat($this->stream); + } + + /** + * Reports a regular file so that `is_file()` and `filesize()` behave like + * they do for a real session file. + * + * @return array + */ + public function url_stat(): array + { + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0o100600, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 5, + 'atime' => time(), + 'mtime' => time(), + 'ctime' => time(), + 'blksize' => -1, + 'blocks' => -1, + ]; + } +} diff --git a/tests/_support/Session/FileHandlerCloseFail.php b/tests/_support/Session/FileHandlerCloseFail.php new file mode 100644 index 000000000000..7ccd5828f93a --- /dev/null +++ b/tests/_support/Session/FileHandlerCloseFail.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Session; + +use CodeIgniter\Session\Handlers\FileHandler; + +/** + * A {@see FileHandler} whose `close()` always reports failure, so that the + * alternative branch of {@see FileHandler::destroy()} can be exercised. + * + * @internal + */ +final class FileHandlerCloseFail extends FileHandler +{ + public function close(): bool + { + return false; + } +} diff --git a/tests/system/Session/Handlers/FileHandlerTest.php b/tests/system/Session/Handlers/FileHandlerTest.php new file mode 100644 index 000000000000..844cec3310e1 --- /dev/null +++ b/tests/system/Session/Handlers/FileHandlerTest.php @@ -0,0 +1,587 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Session\Handlers; + +use Closure; +use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\TestLogger; +use Config\Logger as LoggerConfig; +use Config\Session as SessionConfig; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Session\FaultyStream; +use Tests\Support\Session\FileHandlerCloseFail; + +/** + * @internal + */ +#[Group('Others')] +final class FileHandlerTest extends CIUnitTestCase +{ + private string $sessionDriver = FileHandler::class; + private string $sessionName = 'ci_session'; + private string $userIpAddress = '127.0.0.1'; + private string $tempPath; + private string $originalSavePath; + private string $originalSidBits; + private string $originalSidLength; + + protected function setUp(): void + { + parent::setUp(); + + if (! in_array('faulty', stream_get_wrappers(), true)) { + stream_wrapper_register('faulty', FaultyStream::class); + } + + $this->originalSavePath = (string) ini_get('session.save_path'); + $this->originalSidBits = (string) ini_get('session.sid_bits_per_character'); + $this->originalSidLength = (string) ini_get('session.sid_length'); + + $this->tempPath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'FileHandlerTest_' . bin2hex(random_bytes(6)); + mkdir($this->tempPath, 0700, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + ini_set('session.save_path', $this->originalSavePath); + ini_set('session.sid_bits_per_character', $this->originalSidBits); + ini_set('session.sid_length', $this->originalSidLength); + + FaultyStream::reset(); + + $this->removeDirectory($this->tempPath); + } + + /** + * @param array $options Replace values for `Config\Session`. + */ + protected function getInstance($options = []): FileHandler + { + $defaults = [ + 'driver' => $this->sessionDriver, + 'cookieName' => $this->sessionName, + 'expiration' => 7200, + 'savePath' => $this->tempPath, + 'matchIP' => false, + 'timeToUpdate' => 300, + 'regenerateDestroy' => false, + ]; + $sessionConfig = new SessionConfig(); + $config = array_merge($defaults, $options); + + foreach ($config as $key => $value) { + $sessionConfig->{$key} = $value; + } + + $handler = new FileHandler($sessionConfig, $this->userIpAddress); + $handler->setLogger(new TestLogger(new LoggerConfig())); + + return $handler; + } + + /** + * Runs a callback while suppressing any PHP warnings it raises. + * + * @param Closure(): mixed $callback + */ + private function withSuppressedErrors(Closure $callback): mixed + { + set_error_handler(static fn (int $severity, string $message): bool => true); + + try { + return $callback(); + } finally { + restore_error_handler(); + } + } + + private function removeDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $items = scandir($path); + + foreach ($items === false ? [] : $items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $target = $path . DIRECTORY_SEPARATOR . $item; + + if (is_dir($target)) { + $this->removeDirectory($target); + } else { + @unlink($target); + } + } + + @chmod($path, 0700); + @rmdir($path); + } + + private static function sessionId(string $char): string + { + return str_repeat($char, 32); + } + + // -------------------------------------------------------------------- + // Constructor + // -------------------------------------------------------------------- + + public function testConstructorTrimsSavePath(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath . '//']); + + $this->assertSame($this->tempPath, $this->getPrivateProperty($handler, 'savePath')); + $this->assertSame($this->tempPath, ini_get('session.save_path')); + $this->assertSame('[0-9a-f]{32}', $this->getPrivateProperty($handler, 'sessionIDRegex')); + } + + public function testConstructorUsesEmptyIniSavePath(): void + { + ini_set('session.save_path', ''); + + $handler = $this->getInstance(['savePath' => '']); + + $this->assertSame(rtrim(WRITEPATH . 'session', '/\\'), $this->getPrivateProperty($handler, 'savePath')); + } + + public function testConstructorUsesIniSavePath(): void + { + ini_set('session.save_path', $this->tempPath . '/'); + + $handler = $this->getInstance(['savePath' => '']); + + $this->assertSame($this->tempPath, $this->getPrivateProperty($handler, 'savePath')); + } + + public function testConstructorForcesSessionIDRegexSettings(): void + { + ini_set('session.sid_bits_per_character', '5'); + ini_set('session.sid_length', '26'); + + $this->getInstance(); + + $this->assertSame('4', ini_get('session.sid_bits_per_character')); + $this->assertSame('32', ini_get('session.sid_length')); + } + + // -------------------------------------------------------------------- + // open() + // -------------------------------------------------------------------- + + public function testOpenExistingDirectory(): void + { + $handler = $this->getInstance(); + + $this->assertTrue($handler->open($this->tempPath, $this->sessionName)); + $this->assertSame($this->tempPath . '/' . $this->sessionName, $this->getPrivateProperty($handler, 'filePath')); + } + + public function testOpenWithMatchIP(): void + { + $handler = $this->getInstance(['matchIP' => true]); + + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame( + $this->tempPath . '/' . $this->sessionName . md5($this->userIpAddress), + $this->getPrivateProperty($handler, 'filePath'), + ); + } + + public function testOpenCreatesMissingDirectory(): void + { + $handler = $this->getInstance(); + + $path = $this->tempPath . '/new/nested'; + + $this->assertTrue($handler->open($path, $this->sessionName)); + $this->assertDirectoryExists($path); + } + + public function testOpenThrowsForInvalidSavePath(): void + { + file_put_contents($this->tempPath . '/blocker', 'data'); + + $handler = $this->getInstance(); + + $this->expectException(SessionException::class); + $this->withSuppressedErrors(fn (): bool => $handler->open($this->tempPath . '/blocker/sub', $this->sessionName)); + } + + public function testOpenThrowsForWriteProtectedSavePath(): void + { + $dir = $this->tempPath . '/readonly'; + mkdir($dir, 0700); + chmod($dir, 0555); + + try { + $handler = $this->getInstance(); + + $this->expectException(SessionException::class); + $handler->open($dir, $this->sessionName); + } finally { + chmod($dir, 0755); + } + } + + // -------------------------------------------------------------------- + // read() + // -------------------------------------------------------------------- + + public function testReadNewSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('1'); + + $this->assertSame('', $handler->read($id)); + $this->assertTrue($this->getPrivateProperty($handler, 'fileNew')); + $this->assertSame(md5(''), $this->getPrivateProperty($handler, 'fingerprint')); + + $handler->close(); + } + + public function testReadExistingSession(): void + { + $id = self::sessionId('2'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + file_put_contents($this->tempPath . '/' . $this->sessionName . $id, $data); + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame($data, $handler->read($id)); + $this->assertSame(md5($data), $this->getPrivateProperty($handler, 'fingerprint')); + + $handler->close(); + } + + public function testReadTwiceWithoutCloseRewinds(): void + { + $id = self::sessionId('3'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame('', $handler->read($id)); + $this->assertTrue($handler->write($id, $data)); + + $this->assertSame($data, $handler->read($id)); + $this->assertSame(md5($data), $this->getPrivateProperty($handler, 'fingerprint')); + + $handler->close(); + } + + public function testReadFailsWhenFileCannotBeOpened(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('4'); + mkdir($this->tempPath . '/' . $this->sessionName . $id, 0700, true); + + $result = $this->withSuppressedErrors(static fn (): false|string => $handler->read($id)); + + $this->assertFalse($result); + $this->assertLogContains('error', 'Session: Unable to open file'); + } + + public function testReadFailsWhenFileCannotBeLocked(): void + { + $handler = $this->getInstance(); + $this->setPrivateProperty($handler, 'filePath', 'faulty://'); + + FaultyStream::failLock(true); + + $this->assertFalse($handler->read(self::sessionId('5'))); + $this->assertLogContains('error', 'Session: Unable to obtain lock'); + } + + public function testReadBreaksOutOfLoopWhenFreadFails(): void + { + $handler = $this->getInstance(); + $this->setPrivateProperty($handler, 'filePath', 'faulty://'); + + FaultyStream::failRead(true); + + $this->assertSame('', $handler->read(self::sessionId('6'))); + $this->assertSame(md5(''), $this->getPrivateProperty($handler, 'fingerprint')); + } + + // -------------------------------------------------------------------- + // write() + // -------------------------------------------------------------------- + + public function testWriteFailsWhenNoFileHandle(): void + { + $handler = $this->getInstance(); + + $this->assertFalse($handler->write(self::sessionId('a'), 'data')); + } + + public function testReadWriteRoundtrip(): void + { + $id = self::sessionId('b'); + $data = '__ci_last_regenerate|i:1664607454;key|s:5:"value";'; + + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertSame('', $handler->read($id)); + $this->assertTrue($handler->write($id, $data)); + // Identical data on a new file returns true without writing again. + $this->assertTrue($handler->write($id, $data)); + + $handler->close(); + + $this->assertSame($data, file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + + // A second handler reads the file back; an identical write just touches it. + $handler2 = $this->getInstance(); + $handler2->open($this->tempPath, $this->sessionName); + + $this->assertSame($data, $handler2->read($id)); + $this->assertTrue($handler2->write($id, $data)); + + // Different data truncates and rewrites the existing file. + $data2 = 'changed|i:1664607455;key|s:6:"value2";'; + $this->assertTrue($handler2->write($id, $data2)); + + $handler2->close(); + + $this->assertSame($data2, file_get_contents($this->tempPath . '/' . $this->sessionName . $id)); + } + + public function testWriteEmptyData(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('c'); + $handler->read($id); + $this->setPrivateProperty($handler, 'fingerprint', 'different'); + + $this->assertTrue($handler->write($id, '')); + $this->assertSame(md5(''), $this->getPrivateProperty($handler, 'fingerprint')); + + $handler->close(); + } + + public function testWriteWithDifferentSessionID(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $handler->read(self::sessionId('d')); + + $newId = self::sessionId('e'); + $this->assertTrue($handler->write($newId, 'data')); + $this->assertSame($newId, $this->getPrivateProperty($handler, 'sessionID')); + + $handler->close(); + } + + public function testWriteFailsWhenFwriteFails(): void + { + $handler = $this->getInstance(); + $this->setPrivateProperty($handler, 'fileHandle', fopen('faulty://write', 'c+b')); + $this->setPrivateProperty($handler, 'fingerprint', 'different'); + + FaultyStream::failWrite(true); + + $this->assertFalse($handler->write(self::sessionId('f'), 'data')); + $this->assertSame(md5(''), $this->getPrivateProperty($handler, 'fingerprint')); + $this->assertLogContains('error', 'Session: Unable to write data.'); + } + + // -------------------------------------------------------------------- + // close() + // -------------------------------------------------------------------- + + public function testCloseWithOpenFile(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + $handler->read(self::sessionId('1')); + + $this->assertTrue($handler->close()); + $this->assertNull($this->getPrivateProperty($handler, 'fileHandle')); + $this->assertFalse($this->getPrivateProperty($handler, 'fileNew')); + } + + public function testCloseWithoutOpenFile(): void + { + $handler = $this->getInstance(); + + $this->assertTrue($handler->close()); + } + + // -------------------------------------------------------------------- + // destroy() + // -------------------------------------------------------------------- + + public function testDestroyExistingSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('2'); + $handler->read($id); + $handler->close(); + + $this->assertFileExists($this->tempPath . '/' . $this->sessionName . $id); + $this->assertTrue($handler->destroy($id)); + $this->assertFileDoesNotExist($this->tempPath . '/' . $this->sessionName . $id); + } + + public function testDestroyNonexistentSession(): void + { + $handler = $this->getInstance(); + $handler->open($this->tempPath, $this->sessionName); + $handler->close(); + + $this->assertTrue($handler->destroy(self::sessionId('3'))); + } + + public function testDestroyWhenCloseFailsWithExistingFile(): void + { + $handler = $this->getCloseFailHandler(); + $handler->open($this->tempPath, $this->sessionName); + + $id = self::sessionId('4'); + $handler->read($id); + + $this->assertFileExists($this->tempPath . '/' . $this->sessionName . $id); + $this->assertTrue($handler->destroy($id)); + $this->assertFileDoesNotExist($this->tempPath . '/' . $this->sessionName . $id); + } + + public function testDestroyWhenCloseFailsWithoutFile(): void + { + $handler = $this->getCloseFailHandler(); + $handler->open($this->tempPath, $this->sessionName); + + $this->assertTrue($handler->destroy(self::sessionId('5'))); + } + + public function testDestroyWhenCloseFailsWithoutFilePath(): void + { + $handler = $this->getCloseFailHandler(); + + $this->assertFalse($handler->destroy(self::sessionId('6'))); + } + + private function getCloseFailHandler(): FileHandlerCloseFail + { + $sessionConfig = new SessionConfig(); + $sessionConfig->driver = FileHandlerCloseFail::class; + $sessionConfig->cookieName = $this->sessionName; + $sessionConfig->savePath = $this->tempPath; + $sessionConfig->matchIP = false; + + $handler = new FileHandlerCloseFail($sessionConfig, $this->userIpAddress); + $handler->setLogger(new TestLogger(new LoggerConfig())); + + return $handler; + } + + // -------------------------------------------------------------------- + // gc() + // -------------------------------------------------------------------- + + public function testGCWhenSavePathMissing(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath . '/missing']); + + $this->assertFalse($handler->gc(3600)); + $this->assertLogContains('debug', "Session: Garbage collector couldn't list files under directory"); + } + + public function testGCWhenDirectoryCannotBeOpened(): void + { + $dir = $this->tempPath . '/unreadable'; + mkdir($dir, 0700); + chmod($dir, 0000); + + try { + $handler = $this->getInstance(['savePath' => $dir]); + + $result = $this->withSuppressedErrors(static fn (): false|int => $handler->gc(3600)); + + $this->assertFalse($result); + $this->assertLogContains('debug', "Session: Garbage collector couldn't list files under directory"); + } finally { + chmod($dir, 0755); + } + } + + public function testGCRemovesExpiredSessionsOnly(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath]); + + $expired = $this->tempPath . '/' . $this->sessionName . self::sessionId('a'); + file_put_contents($expired, 'data'); + touch($expired, time() - 4000); + + $recent = $this->tempPath . '/' . $this->sessionName . self::sessionId('b'); + file_put_contents($recent, 'data'); + touch($recent); + + $directory = $this->tempPath . '/' . $this->sessionName . self::sessionId('c'); + mkdir($directory, 0700); + + $foreign = $this->tempPath . '/not-a-session-file'; + file_put_contents($foreign, 'data'); + touch($foreign, time() - 4000); + + $nonHex = $this->tempPath . '/' . $this->sessionName . str_repeat('z', 32); + file_put_contents($nonHex, 'data'); + touch($nonHex, time() - 4000); + + $this->assertSame(1, $handler->gc(3600)); + + $this->assertFileDoesNotExist($expired); + $this->assertFileExists($recent); + $this->assertDirectoryExists($directory); + $this->assertFileExists($foreign); + $this->assertFileExists($nonHex); + } + + public function testGCWithMatchIP(): void + { + $handler = $this->getInstance(['savePath' => $this->tempPath, 'matchIP' => true]); + + $expired = $this->tempPath . '/' . $this->sessionName . md5($this->userIpAddress) . self::sessionId('a'); + file_put_contents($expired, 'data'); + touch($expired, time() - 4000); + + $withoutIp = $this->tempPath . '/' . $this->sessionName . self::sessionId('b'); + file_put_contents($withoutIp, 'data'); + touch($withoutIp, time() - 4000); + + $this->assertSame(1, $handler->gc(3600)); + + $this->assertFileDoesNotExist($expired); + $this->assertFileExists($withoutIp); + } +}