[PHP-WEBMASTER] [web-php] master: Fix uncaught PDOException in manual lookup when sqlite is unavailable

[email protected] (Joe Ferguson via Derick Rethans)
Newsgroups php.webmaster
Message-ID <[email protected]>
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'));
+        }
+    }
+}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.