Author: Joe Ferguson (svpernova09)
Committer: Derick Rethans (derickr)
Date: 2026-08-18T17:16:45+01:00

Commit: 
https://github.com/php/web-php/commit/65265f93924ad23582624825a8fd4c6b6ebba0b4
Raw diff: 
https://github.com/php/web-php/commit/65265f93924ad23582624825a8fd4c6b6ebba0b4.diff

Fix uncaught PDOException in manual lookup when sqlite is unavailable

Changed paths:
  A  tests/Unit/ManualLookup/FindManualPageTest.php
  M  include/manual-lookup.inc


Diff:

diff --git a/include/manual-lookup.inc b/include/manual-lookup.inc
index 4fbc3cd949..8d988041d1 100644
--- a/include/manual-lookup.inc
+++ b/include/manual-lookup.inc
@@ -112,7 +112,8 @@ function find_manual_page($lang, $keyword)
         if (in_array('sqlite', PDO::getAvailableDrivers(), true)) {
             if (file_exists(ProjectGlobals::getBackendRoot() . 
'/manual-lookup.sqlite')) {
                 try {
-                    $dbh = new PDO( 'sqlite:' . 
ProjectGlobals::getBackendRoot() . '/manual-lookup.sqlite', '', '', 
[PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true] );
+                    // Check prepare()/execute() for false to fall back to the 
slow search
+                    $dbh = new PDO( 'sqlite:' . 
ProjectGlobals::getBackendRoot() . '/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);
                 }
@@ -211,7 +212,8 @@ function find_manual_page($lang, $keyword)
                 }
             }
         } else {
-            error_noservice();
+            // prepare() failed, fall back to the slow search
+            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..251e7ef88a
--- /dev/null
+++ b/tests/Unit/ManualLookup/FindManualPageTest.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace {
+    // include/manual-lookup.inc defines global functions and depends on the 
global
+    // get_manual_search_sections(). That lives in include/site.inc, which 
cannot be
+    // required in isolation, so repeat the list from site.inc here.
+    if (!function_exists('get_manual_search_sections')) {
+        /** @return list<string> */
+        function get_manual_search_sections(): array
+        {
+            return [
+                "", "book.", "ref.", "function.", "class.", "enum.",
+                "features.", "control-structures.", "language.",
+                "about.", "faq.",
+            ];
+        }
+    }
+
+    require_once phpweb\ProjectGlobals::getProjectRoot() . 
'/include/manual-lookup.inc';
+}
+
+namespace phpweb\Test\Unit\ManualLookup {
+
+    use phpweb\ProjectGlobals;
+    use PHPUnit\Framework;
+
+    #[Framework\Attributes\CoversFunction('find_manual_page')]
+    #[Framework\Attributes\CoversFunction('find_manual_page_slow')]
+    #[Framework\Attributes\RunTestsInSeparateProcesses]
+    #[Framework\Attributes\PreserveGlobalState(false)]
+    final class FindManualPageTest extends Framework\TestCase
+    {
+        // Manual pages checked into public/manual/en/ that the searches below 
resolve to.
+        private const SLOW_PATH_PAGE = '/manual/en/function.strpos.php';
+
+        private const FAST_PATH_PAGE = '/manual/en/function.rtrim.php';
+
+        private string $database;
+
+        protected function setUp(): void
+        {
+            $this->database = ProjectGlobals::getBackendRoot() . 
'/manual-lookup.sqlite';
+
+            // A database here means a live checkout with an rsynced manual, 
not a test one
+            if (file_exists($this->database)) {
+                self::markTestSkipped('manual-lookup.sqlite is present, 
refusing to overwrite it');
+            }
+        }
+
+        protected function tearDown(): void
+        {
+            @unlink($this->database);
+        }
+
+        /**
+         * Regression test for the production fatal:
+         *   Uncaught PDOException: SQLSTATE[HY000]: General error: 8
+         *   attempt to write a readonly database in include/manual-lookup.inc
+         *
+         * A read-only, locked or truncated database must fall back to the slow
+         * search rather than throwing.
+         */
+        public function testFallsBackToSlowSearchWhenSqliteQueryFails(): void
+        {
+            file_put_contents($this->database, 'this is not a sqlite 
database');
+
+            self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 
'strpos'));
+        }
+
+        public function 
testFallsBackToSlowSearchForDottedKeywordWhenSqliteQueryFails(): void
+        {
+            // A dotted keyword takes the other SQL branch, which must fall 
back too
+            file_put_contents($this->database, 'this is not a sqlite 
database');
+
+            self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 
'function.strpos'));
+        }
+
+        public function testFallsBackToSlowSearchWhenNoDatabasePresent(): void
+        {
+            self::assertSame(self::SLOW_PATH_PAGE, find_manual_page('en', 
'strpos'));
+        }
+
+        /**
+         * The fast path maps the keyword to a different page than the slow 
search
+         * would find, so a match on it proves the database was really used.
+         */
+        #[Framework\Attributes\RequiresPhpExtension('pdo_sqlite')]
+        public function testUsesSqliteFastPathWhenDatabaseIsValid(): void
+        {
+            $dbh = new \PDO('sqlite:' . $this->database);
+            $dbh->exec('CREATE TABLE fs (lang TEXT, prefix TEXT, keyword TEXT, 
name TEXT, prio INT)');
+            $dbh->exec(sprintf(
+                "INSERT INTO fs (lang, prefix, keyword, name, prio) VALUES 
('en', 'function.', 'strpos', '%s', 3)",
+                self::FAST_PATH_PAGE,
+            ));
+            $dbh = null;
+
+            self::assertSame(self::FAST_PATH_PAGE, find_manual_page('en', 
'strpos'));
+        }
+    }
+}

Reply via email to