From 804460a044201689f91e91d110addd5f34144d15 Mon Sep 17 00:00:00 2001 From: Joe Ferguson Date: Tue, 14 Jul 2026 10:54:36 -0500 Subject: [PATCH] Fix uncaught PDOException in manual lookup when sqlite is unavailable find_manual_page() was written for PDO's pre-8.0 ERRMODE_SILENT default, where a failed prepare()/execute() returns false and the code falls back to find_manual_page_slow(). PHP 8 defaults to ERRMODE_EXCEPTION, so those failures now throw and escaped uncaught -- 19,892 'General error: 8 attempt to write a readonly database' fatals in a 15-day window, almost all reached via the 404 handler (error.php -> find_manual_page). Restore ERRMODE_SILENT on the connection so the existing fallback works, and make the bare-keyword branch fall back to the slow search instead of calling error_noservice() (a hard error page) when prepare() fails. Add integration tests covering both SQL branches with a broken database, a missing database, and the healthy fast path. --- include/manual-lookup.inc | 14 ++- .../Unit/ManualLookup/FindManualPageTest.php | 114 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/ManualLookup/FindManualPageTest.php diff --git a/include/manual-lookup.inc b/include/manual-lookup.inc index 9d3ff4d141..df48f29776 100644 --- a/include/manual-lookup.inc +++ b/include/manual-lookup.inc @@ -110,7 +110,13 @@ function find_manual_page($lang, $keyword) if (in_array('sqlite', PDO::getAvailableDrivers(), true)) { if (file_exists($_SERVER['DOCUMENT_ROOT'] . '/backend/manual-lookup.sqlite')) { try { - $dbh = new PDO( 'sqlite:' . $_SERVER['DOCUMENT_ROOT'] . '/backend/manual-lookup.sqlite', '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true] ); + // ERRMODE_SILENT (the default before PHP 8.0) is required: the query + // handling below relies on prepare()/execute() returning false on + // failure so it can fall back to find_manual_page_slow(). Under PHP 8's + // default ERRMODE_EXCEPTION those failures throw instead, which escaped + // uncaught (e.g. "attempt to write a readonly database" on a locked or + // read-only sqlite file) and produced fatals. + $dbh = new PDO( 'sqlite:' . $_SERVER['DOCUMENT_ROOT'] . '/backend/manual-lookup.sqlite', '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT] ); } catch (PDOException $e) { return find_manual_page_slow($lang, $keyword); } @@ -209,7 +215,11 @@ function find_manual_page($lang, $keyword) } } } else { - error_noservice(); + // The sqlite fast-path failed for this query (e.g. prepare() returned false + // on a broken/locked database). Fall back to the filesystem search like the + // other failure paths above, rather than showing a hard "service not working" + // page for what is only a best-effort lookup optimisation. + return find_manual_page_slow($langs[0], $kw); } } diff --git a/tests/Unit/ManualLookup/FindManualPageTest.php b/tests/Unit/ManualLookup/FindManualPageTest.php new file mode 100644 index 0000000000..a950ecdd12 --- /dev/null +++ b/tests/Unit/ManualLookup/FindManualPageTest.php @@ -0,0 +1,114 @@ +root = sys_get_temp_dir() . '/phpweb-ml-' . uniqid('', true); + mkdir($this->root . '/backend', 0777, true); + mkdir($this->root . '/manual/en', 0777, true); + // Filesystem (slow-path) target for the keyword "echo". + file_put_contents($this->root . '/manual/en/function.echo.php', 'originalDocumentRoot = $_SERVER['DOCUMENT_ROOT'] ?? null; + $_SERVER['DOCUMENT_ROOT'] = $this->root; + } + + protected function tearDown(): void + { + if ($this->originalDocumentRoot === null) { + unset($_SERVER['DOCUMENT_ROOT']); + } else { + $_SERVER['DOCUMENT_ROOT'] = $this->originalDocumentRoot; + } + + array_map('unlink', glob($this->root . '/backend/*') ?: []); + array_map('unlink', glob($this->root . '/manual/en/*') ?: []); + @rmdir($this->root . '/backend'); + @rmdir($this->root . '/manual/en'); + @rmdir($this->root . '/manual'); + @rmdir($this->root); + } + + /** + * Regression test for the production fatal: + * Uncaught PDOException: SQLSTATE[HY000]: General error: 8 + * attempt to write a readonly database in include/manual-lookup.inc + * + * When the sqlite fast-path fails for ANY reason (a read-only/locked database, + * or a corrupt/truncated one from an interrupted rsync), find_manual_page() must + * fall back to the filesystem search instead of throwing an uncaught exception. + */ + public function testFallsBackToSlowSearchWhenSqliteQueryFails(): void + { + file_put_contents($this->root . '/backend/manual-lookup.sqlite', 'this is not a sqlite database'); + + $result = find_manual_page('en', 'echo'); + + self::assertSame('/manual/en/function.echo.php', $result); + } + + public function testFallsBackToSlowSearchForDottedKeywordWhenSqliteQueryFails(): void + { + // A dotted keyword takes the other SQL branch in find_manual_page(); it must + // fall back to the filesystem search on a broken database too. + file_put_contents($this->root . '/backend/manual-lookup.sqlite', 'this is not a sqlite database'); + + $result = find_manual_page('en', 'function.echo'); + + self::assertSame('/manual/en/function.echo.php', $result); + } + + #[Framework\Attributes\RequiresPhpExtension('pdo_sqlite')] + public function testUsesSqliteFastPathWhenDatabaseIsValid(): void + { + $this->buildValidDatabase(); + + $result = find_manual_page('en', 'function.echo'); + + self::assertSame('/manual/en/function.echo.php', $result); + } + + public function testFallsBackToSlowSearchWhenNoDatabasePresent(): void + { + // No backend/manual-lookup.sqlite at all -> slow (filesystem) search only. + $result = find_manual_page('en', 'echo'); + + self::assertSame('/manual/en/function.echo.php', $result); + } + + private function buildValidDatabase(): void + { + $dbh = new \PDO('sqlite:' . $this->root . '/backend/manual-lookup.sqlite'); + $dbh->exec('CREATE TABLE fs (lang TEXT, prefix TEXT, keyword TEXT, name TEXT, prio INT)'); + $dbh->exec("INSERT INTO fs (lang, prefix, keyword, name, prio) VALUES ('en', 'function.', 'echo', '/manual/en/function.echo.php', 3)"); + $dbh = null; + } + } +}