Skip to content

Commit 804460a

Browse files
committed
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.
1 parent 9ffddc4 commit 804460a

2 files changed

Lines changed: 126 additions & 2 deletions

File tree

include/manual-lookup.inc

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ function find_manual_page($lang, $keyword)
110110
if (in_array('sqlite', PDO::getAvailableDrivers(), true)) {
111111
if (file_exists($_SERVER['DOCUMENT_ROOT'] . '/backend/manual-lookup.sqlite')) {
112112
try {
113-
$dbh = new PDO( 'sqlite:' . $_SERVER['DOCUMENT_ROOT'] . '/backend/manual-lookup.sqlite', '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true] );
113+
// ERRMODE_SILENT (the default before PHP 8.0) is required: the query
114+
// handling below relies on prepare()/execute() returning false on
115+
// failure so it can fall back to find_manual_page_slow(). Under PHP 8's
116+
// default ERRMODE_EXCEPTION those failures throw instead, which escaped
117+
// uncaught (e.g. "attempt to write a readonly database" on a locked or
118+
// read-only sqlite file) and produced fatals.
119+
$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] );
114120
} catch (PDOException $e) {
115121
return find_manual_page_slow($lang, $keyword);
116122
}
@@ -209,7 +215,11 @@ function find_manual_page($lang, $keyword)
209215
}
210216
}
211217
} else {
212-
error_noservice();
218+
// The sqlite fast-path failed for this query (e.g. prepare() returned false
219+
// on a broken/locked database). Fall back to the filesystem search like the
220+
// other failure paths above, rather than showing a hard "service not working"
221+
// page for what is only a best-effort lookup optimisation.
222+
return find_manual_page_slow($langs[0], $kw);
213223
}
214224
}
215225

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace {
6+
// include/manual-lookup.inc defines global functions and depends on the global
7+
// get_manual_search_sections() (normally from include/site.inc). Provide the same
8+
// section list here so the file can be exercised without pulling in all of site.inc.
9+
if (!function_exists('get_manual_search_sections')) {
10+
function get_manual_search_sections(): array
11+
{
12+
return ['', 'book.', 'ref.', 'function.', 'class.', 'enum.', 'features.', 'control-structures.', 'language.', 'about.', 'faq.'];
13+
}
14+
}
15+
16+
require_once __DIR__ . '/../../../include/manual-lookup.inc';
17+
}
18+
19+
namespace phpweb\Test\Unit\ManualLookup {
20+
21+
use PHPUnit\Framework;
22+
23+
#[Framework\Attributes\CoversFunction('find_manual_page')]
24+
#[Framework\Attributes\CoversFunction('find_manual_page_slow')]
25+
final class FindManualPageTest extends Framework\TestCase
26+
{
27+
private string $root;
28+
29+
private ?string $originalDocumentRoot;
30+
31+
protected function setUp(): void
32+
{
33+
$this->root = sys_get_temp_dir() . '/phpweb-ml-' . uniqid('', true);
34+
mkdir($this->root . '/backend', 0777, true);
35+
mkdir($this->root . '/manual/en', 0777, true);
36+
// Filesystem (slow-path) target for the keyword "echo".
37+
file_put_contents($this->root . '/manual/en/function.echo.php', '<?php');
38+
39+
$this->originalDocumentRoot = $_SERVER['DOCUMENT_ROOT'] ?? null;
40+
$_SERVER['DOCUMENT_ROOT'] = $this->root;
41+
}
42+
43+
protected function tearDown(): void
44+
{
45+
if ($this->originalDocumentRoot === null) {
46+
unset($_SERVER['DOCUMENT_ROOT']);
47+
} else {
48+
$_SERVER['DOCUMENT_ROOT'] = $this->originalDocumentRoot;
49+
}
50+
51+
array_map('unlink', glob($this->root . '/backend/*') ?: []);
52+
array_map('unlink', glob($this->root . '/manual/en/*') ?: []);
53+
@rmdir($this->root . '/backend');
54+
@rmdir($this->root . '/manual/en');
55+
@rmdir($this->root . '/manual');
56+
@rmdir($this->root);
57+
}
58+
59+
/**
60+
* Regression test for the production fatal:
61+
* Uncaught PDOException: SQLSTATE[HY000]: General error: 8
62+
* attempt to write a readonly database in include/manual-lookup.inc
63+
*
64+
* When the sqlite fast-path fails for ANY reason (a read-only/locked database,
65+
* or a corrupt/truncated one from an interrupted rsync), find_manual_page() must
66+
* fall back to the filesystem search instead of throwing an uncaught exception.
67+
*/
68+
public function testFallsBackToSlowSearchWhenSqliteQueryFails(): void
69+
{
70+
file_put_contents($this->root . '/backend/manual-lookup.sqlite', 'this is not a sqlite database');
71+
72+
$result = find_manual_page('en', 'echo');
73+
74+
self::assertSame('/manual/en/function.echo.php', $result);
75+
}
76+
77+
public function testFallsBackToSlowSearchForDottedKeywordWhenSqliteQueryFails(): void
78+
{
79+
// A dotted keyword takes the other SQL branch in find_manual_page(); it must
80+
// fall back to the filesystem search on a broken database too.
81+
file_put_contents($this->root . '/backend/manual-lookup.sqlite', 'this is not a sqlite database');
82+
83+
$result = find_manual_page('en', 'function.echo');
84+
85+
self::assertSame('/manual/en/function.echo.php', $result);
86+
}
87+
88+
#[Framework\Attributes\RequiresPhpExtension('pdo_sqlite')]
89+
public function testUsesSqliteFastPathWhenDatabaseIsValid(): void
90+
{
91+
$this->buildValidDatabase();
92+
93+
$result = find_manual_page('en', 'function.echo');
94+
95+
self::assertSame('/manual/en/function.echo.php', $result);
96+
}
97+
98+
public function testFallsBackToSlowSearchWhenNoDatabasePresent(): void
99+
{
100+
// No backend/manual-lookup.sqlite at all -> slow (filesystem) search only.
101+
$result = find_manual_page('en', 'echo');
102+
103+
self::assertSame('/manual/en/function.echo.php', $result);
104+
}
105+
106+
private function buildValidDatabase(): void
107+
{
108+
$dbh = new \PDO('sqlite:' . $this->root . '/backend/manual-lookup.sqlite');
109+
$dbh->exec('CREATE TABLE fs (lang TEXT, prefix TEXT, keyword TEXT, name TEXT, prio INT)');
110+
$dbh->exec("INSERT INTO fs (lang, prefix, keyword, name, prio) VALUES ('en', 'function.', 'echo', '/manual/en/function.echo.php', 3)");
111+
$dbh = null;
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)