Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions include/manual-lookup.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
114 changes: 114 additions & 0 deletions tests/Unit/ManualLookup/FindManualPageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace {
// include/manual-lookup.inc defines global functions and depends on the global
// get_manual_search_sections() (normally from include/site.inc). Provide the same
// section list here so the file can be exercised without pulling in all of site.inc.
if (!function_exists('get_manual_search_sections')) {
function get_manual_search_sections(): array
{
return ['', 'book.', 'ref.', 'function.', 'class.', 'enum.', 'features.', 'control-structures.', 'language.', 'about.', 'faq.'];
}
}

require_once __DIR__ . '/../../../include/manual-lookup.inc';
}

namespace phpweb\Test\Unit\ManualLookup {

use PHPUnit\Framework;

#[Framework\Attributes\CoversFunction('find_manual_page')]
#[Framework\Attributes\CoversFunction('find_manual_page_slow')]
final class FindManualPageTest extends Framework\TestCase
{
private string $root;

private ?string $originalDocumentRoot;

protected function setUp(): void
{
$this->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', '<?php');

$this->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;
}
}
}