From 170bc92d898931e73717055d98ed321a0d398f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 17 Aug 2026 14:51:50 +0200 Subject: [PATCH 1/2] Add randomized SQLite database storage Store new databases in a protected randomized directory and record the relative location in a portable manifest. Preserve existing fixed database paths for a separate migration step. --- composer.json | 9 +- .../constants.php | 15 +- .../wp-includes/sqlite/class-wp-sqlite-db.php | 69 ---- .../sqlite/class-wp-sqlite-storage.php | 260 +++++++++++++++ .../wp-includes/sqlite/db.php | 14 + tests/phpunit/WP_SQLite_Storage_Test.php | 314 ++++++++++++++++++ 6 files changed, 601 insertions(+), 80 deletions(-) create mode 100644 packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php create mode 100644 tests/phpunit/WP_SQLite_Storage_Test.php diff --git a/composer.json b/composer.json index 0d5512527..81bc4657d 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ ], "wp-test-php": [ "@wp-test-ensure-env @no_additional_args", - "rm -rf wordpress/src/wp-content/database/.ht.sqlite @no_additional_args", + "@wp-test-remove-database @no_additional_args", "npm --prefix wordpress run test:php -- @additional_args" ], "wp-test-sqlite-plugin-php": [ @@ -80,8 +80,11 @@ "npm --prefix wordpress run test:e2e -- @additional_args" ], "wp-test-clean": [ - "npm --prefix wordpress run env:clean", - "rm -rf wordpress/src/wp-content/database/.ht.sqlite" + "@wp-test-remove-database", + "npm --prefix wordpress run env:clean" + ], + "wp-test-remove-database": [ + "cd wordpress && node tools/local-env/scripts/docker.js run --rm --no-deps --entrypoint rm php -rf /var/www/src/wp-content/database" ] } } diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index e4e851178..27bd49861 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -37,15 +37,14 @@ } /** - * FQDB is a database file name. If DB_FILE is defined, it is used - * as FQDB. + * FQDB is the absolute path to the SQLite database file. + * + * If DB_FILE is defined, FQDB is defined here using FQDBDIR. When SQLite is + * used without DB_FILE, managed storage defines FQDB after resolving the + * randomized database path. * * @deprecated 3.0.0 Define DB_DIR and DB_FILE instead of overriding FQDB. */ -if ( ! defined( 'FQDB' ) ) { - if ( defined( 'DB_FILE' ) ) { - define( 'FQDB', FQDBDIR . DB_FILE ); - } else { - define( 'FQDB', FQDBDIR . '.ht.sqlite' ); - } +if ( ! defined( 'FQDB' ) && defined( 'DB_FILE' ) ) { + define( 'FQDB', FQDBDIR . DB_FILE ); } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index a50fbc77b..6889e412e 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -432,25 +432,6 @@ public function db_connect( $allow_bail = true ) { $this->init_charset(); } - // Migrate the database file from a legacy path, if it exists. - if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) { - $old_db_path = FQDBDIR . '.ht.sqlite.php'; - - if ( file_exists( $old_db_path ) ) { - if ( ! rename( $old_db_path, FQDB ) ) { - wp_die( 'Failed to rename database file.', 'Error!' ); - } - - foreach ( array( '-wal', '-shm', '-journal' ) as $suffix ) { - if ( file_exists( $old_db_path . $suffix ) ) { - if ( ! rename( $old_db_path . $suffix, FQDB . $suffix ) ) { - wp_die( 'Failed to rename database file.', 'Error!' ); - } - } - } - } - } - if ( null === $this->dbname || '' === $this->dbname ) { $this->bail( 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.', @@ -459,8 +440,6 @@ public function db_connect( $allow_bail = true ) { return false; } - $this->ensure_database_directory( FQDB ); - try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, @@ -775,54 +754,6 @@ public function db_server_info() { return $this->dbh->getAttribute( PDO::ATTR_SERVER_VERSION ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } - /** - * Make sure the SQLite database directory exists and is writable. - * Create .htaccess and index.php files to prevent direct access. - * - * @param string $database_path The path to the SQLite database file. - */ - private function ensure_database_directory( string $database_path ) { - $dir = dirname( $database_path ); - - // Set the umask to 0000 to apply permissions exactly as specified. - // A non-zero umask affects new file and directory permissions. - $umask = umask( 0 ); - - // Ensure database directory. - if ( ! is_dir( $dir ) ) { - if ( ! @mkdir( $dir, 0700, true ) ) { - wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' ); - } - } - if ( ! is_writable( $dir ) ) { - wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' ); - } - - // Ensure .htaccess file to prevent direct access. - $path = $dir . DIRECTORY_SEPARATOR . '.htaccess'; - if ( ! is_file( $path ) ) { - $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX ); - if ( false === $result ) { - wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' ); - } - chmod( $path, 0600 ); - } - - // Ensure index.php file to prevent direct access. - $path = $dir . DIRECTORY_SEPARATOR . 'index.php'; - if ( ! is_file( $path ) ) { - $result = file_put_contents( $path, '', LOCK_EX ); - if ( false === $result ) { - wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' ); - } - chmod( $path, 0600 ); - } - - // Restore the original umask value. - umask( $umask ); - } - - /** * Format MySQL-on-SQLite driver error message. * diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php new file mode 100644 index 000000000..7136b9be0 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php @@ -0,0 +1,260 @@ +database_root = trailingslashit( $database_root ?? FQDBDIR ); + } + + /** + * Initialize the SQLite database storage. + * + * Uses an explicit file path or ":memory:" as provided. Otherwise, initializes + * managed storage with a randomized path unless a legacy database already exists. + * + * @param string|null $database_path Optional explicit database path. + * @return string Absolute path to the SQLite database file, or ":memory:". + */ + public function initialize( ?string $database_path = null ): string { + if ( '' === $database_path ) { + throw new RuntimeException( 'The SQLite database path is invalid.' ); + } + + if ( ':memory:' === $database_path ) { + return $database_path; + } + + // Explicitly provided database path. + if ( null !== $database_path ) { + $this->ensure_database( $database_path ); + return $database_path; + } + + // Reuse an initialized managed database without modifying its storage. + $db_path_file = $this->database_root . self::DATABASE_PATH_FILENAME; + if ( @is_file( $db_path_file ) ) { + $database_path = $this->read_database_path( $db_path_file ); + if ( @is_file( $database_path ) ) { + return $database_path; + } + } else { + $legacy_path = $this->database_root . self::DATABASE_FILENAME; + if ( ! @is_file( $legacy_path ) ) { + $legacy_path .= '.php'; + } + if ( @is_file( $legacy_path ) ) { + return $legacy_path; + } + } + + // Initialize or repair the managed database under a lock. + $this->ensure_protected_directory( $this->database_root ); + $lock = $this->acquire_lock(); + try { + // Publish a new database path. + if ( ! @is_file( $db_path_file ) ) { + $legacy_path = $this->database_root . self::DATABASE_FILENAME; + if ( ! @is_file( $legacy_path ) ) { + $legacy_path .= '.php'; + } + if ( @is_file( $legacy_path ) ) { + return $legacy_path; + } + $this->publish_database_path(); + } + $database_path = $this->read_database_path( $db_path_file ); + + // Initialize a new database. + $this->ensure_database( $database_path ); + return $database_path; + } finally { + fclose( $lock ); + } + } + + /** + * Ensure that a database and its protected directory exist. + * + * @param string $database_path Absolute database path. + */ + private function ensure_database( string $database_path ) { + $this->ensure_protected_directory( dirname( $database_path ) ); + + if ( ! @is_file( $database_path ) ) { + // Create an empty database file with restricted permissions. + $database_handle = @fopen( $database_path, 'c' ); + if ( false === $database_handle ) { + throw new RuntimeException( 'Failed to create the SQLite database file.' ); + } + fclose( $database_handle ); + @chmod( $database_path, 0600 ); + } + } + + /** + * Read and validate the database path file. + * + * @param string $database_path_file Absolute database path file. + * @return string Absolute path to the SQLite database file. + */ + private function read_database_path( string $database_path_file ): string { + try { + // Use include so an unreadable file can be handled without terminating on PHP 7. + $database_path = @include $database_path_file; + } catch ( Throwable $exception ) { + throw new RuntimeException( 'Failed to read the SQLite database path file.', 0, $exception ); + } + + if ( false === $database_path ) { + throw new RuntimeException( 'Failed to read the SQLite database path file.' ); + } + + if ( ! is_string( $database_path ) || '' === $database_path ) { + throw new RuntimeException( 'The SQLite database path file is invalid.' ); + } + + return $database_path; + } + + /** + * Generate a randomized database path and publish it atomically. + */ + private function publish_database_path() { + $directory_name = '.ht.' . bin2hex( random_bytes( self::RANDOM_TOKEN_BYTE_LENGTH ) ); + $directory_path = $this->database_root . $directory_name; + + if ( @file_exists( $directory_path ) ) { + throw new RuntimeException( 'Failed to generate a unique SQLite database path.' ); + } + + $database_path_file = $this->database_root . self::DATABASE_PATH_FILENAME; + $database_path_contents = sprintf( + "database_root . '.ht.' . self::DATABASE_PATH_FILENAME; + + if ( false === @file_put_contents( $temporary_path, $database_path_contents, LOCK_EX ) ) { + throw new RuntimeException( 'Failed to write the SQLite database path file.' ); + } + @chmod( $temporary_path, 0600 ); + + if ( ! @rename( $temporary_path, $database_path_file ) ) { + @unlink( $temporary_path ); + throw new RuntimeException( 'Failed to publish the SQLite database path file.' ); + } + + // This runs before wp_opcache_invalidate() is available. + $opcache_restrict_api = ini_get( 'opcache.restrict_api' ); + $script_filename = isset( $_SERVER['SCRIPT_FILENAME'] ) ? realpath( $_SERVER['SCRIPT_FILENAME'] ) : false; + if ( + function_exists( 'opcache_invalidate' ) + && ( ! $opcache_restrict_api || ( $script_filename && 0 === stripos( $script_filename, $opcache_restrict_api ) ) ) + ) { + opcache_invalidate( $database_path_file, true ); + } + } + + /** + * Ensure that a database directory exists and deny direct access. + * + * @param string $directory Absolute directory path. + */ + private function ensure_protected_directory( string $directory ) { + if ( ! @is_dir( $directory ) ) { + // Create the path one directory at a time to avoid changing the process-wide umask. + $missing_directories = array(); + for ( $path = untrailingslashit( $directory ); ! @is_dir( $path ); $path = dirname( $path ) ) { + $missing_directories[] = $path; + if ( dirname( $path ) === $path ) { + break; + } + } + + foreach ( array_reverse( $missing_directories ) as $path ) { + if ( ! @mkdir( $path, 0700 ) && ! @is_dir( $path ) ) { + throw new RuntimeException( 'Failed to create the SQLite database directory.' ); + } + @chmod( $path, 0700 ); + } + } + + $this->ensure_file( trailingslashit( $directory ) . '.htaccess', 'DENY FROM ALL' ); + $this->ensure_file( trailingslashit( $directory ) . 'index.php', 'database_root . self::LOCK_FILENAME; + $lock_handle = @fopen( $lock_path, 'c' ); + if ( false === $lock_handle ) { + throw new RuntimeException( 'Failed to open the SQLite database storage lock.' ); + } + @chmod( $lock_path, 0600 ); + + if ( ! @flock( $lock_handle, LOCK_EX ) ) { + fclose( $lock_handle ); + throw new RuntimeException( 'Failed to lock the SQLite database storage.' ); + } + return $lock_handle; + } +} diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php index 1fa76de06..645fcb20c 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php @@ -44,6 +44,20 @@ ); } +require_once __DIR__ . '/class-wp-sqlite-storage.php'; + +try { + $database_storage = new WP_SQLite_Storage(); + $database_path = $database_storage->initialize( defined( 'FQDB' ) ? FQDB : null ); +} catch ( Throwable $exception ) { + error_log( 'SQLite database error: ' . (string) $exception ); + wp_die( esc_html( $exception->getMessage() ), 'SQLite database error' ); +} + +if ( ! defined( 'FQDB' ) ) { + define( 'FQDB', $database_path ); +} + require_once __DIR__ . '/../database/load.php'; require_once __DIR__ . '/class-wp-sqlite-db.php'; require_once __DIR__ . '/install-functions.php'; diff --git a/tests/phpunit/WP_SQLite_Storage_Test.php b/tests/phpunit/WP_SQLite_Storage_Test.php new file mode 100644 index 000000000..c8ecc9512 --- /dev/null +++ b/tests/phpunit/WP_SQLite_Storage_Test.php @@ -0,0 +1,314 @@ +temporary_directories as $directory ) { + $this->remove_directory( $directory ); + } + + parent::tear_down(); + } + + public function test_creates_randomized_database_storage() { + $storage_root = $this->create_temporary_directory_path(); + $database_root = $storage_root . '/nested/database'; + $umask = umask( 0777 ); + + try { + $database_path = $this->initialize_managed_storage( $database_root ); + } finally { + umask( $umask ); + } + + $database_path_file = $database_root . '/db-path.php'; + $stored_database_path = require $database_path_file; + + $this->assertSame( $database_path, $stored_database_path ); + $this->assertSame( 1, preg_match( '/\A\.ht\.[0-9a-f]{32}\z/', basename( dirname( $stored_database_path ) ) ) ); + $this->assertFileExists( $database_path ); + $this->assertSame( 0, filesize( $database_path ) ); + $this->assertSame( 0600, fileperms( $database_path ) & 0777 ); + $this->assertSame( 0600, fileperms( $database_path_file ) & 0777 ); + $this->assertSame( 0600, fileperms( $database_root . '/.ht.sqlite.lock' ) & 0777 ); + $this->assertStringContainsString( + 'IMPORTANT: Keep this path secret. When possible, point it outside the document root.', + file_get_contents( $database_path_file ) + ); + $this->assertSame( 0700, fileperms( $storage_root ) & 0777 ); + $this->assertSame( 0700, fileperms( $storage_root . '/nested' ) & 0777 ); + $this->assert_protected_directory( $database_root ); + $this->assert_protected_directory( dirname( $database_path ) ); + } + + public function test_reuses_initialized_storage_without_repairing_protection_files() { + $database_root = $this->create_temporary_directory_path(); + $first_path = $this->initialize_managed_storage( $database_root ); + $this->assertTrue( unlink( $database_root . '/.htaccess' ) ); + $this->assertTrue( unlink( dirname( $first_path ) . '/index.php' ) ); + + $second_path = $this->initialize_managed_storage( $database_root ); + + $this->assertSame( $first_path, $second_path ); + $this->assertFileDoesNotExist( $database_root . '/.htaccess' ); + $this->assertFileDoesNotExist( dirname( $second_path ) . '/index.php' ); + } + + public function test_reuses_initialized_storage_with_read_only_database_root() { + $database_root = $this->create_temporary_directory_path(); + $database_path = $this->initialize_managed_storage( $database_root ); + $this->assertTrue( chmod( $database_root, 0500 ) ); + + try { + $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + } finally { + $this->assertTrue( chmod( $database_root, 0700 ) ); + } + } + + public function test_recovers_an_interrupted_database_path_write() { + $database_root = $this->create_temporary_directory_path(); + $this->create_directory( $database_root ); + file_put_contents( $database_root . '/.ht.db-path.php', 'interrupted write' ); + + $database_path = $this->initialize_managed_storage( $database_root ); + + $this->assertFileExists( $database_path ); + $this->assertFileExists( $database_root . '/db-path.php' ); + $this->assertFileDoesNotExist( $database_root . '/.ht.db-path.php' ); + } + + public function test_database_path_file_returns_path_without_direct_output() { + $database_root = $this->create_temporary_directory_path(); + $database_path = $this->initialize_managed_storage( $database_root ); + + ob_start(); + $stored_database_path = require $database_root . '/db-path.php'; + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + $this->assertSame( $database_path, $stored_database_path ); + } + + public function test_rejects_a_database_path_file_that_does_not_return_a_path() { + $database_root = $this->create_temporary_directory_path(); + $this->create_directory( $database_root ); + file_put_contents( $database_root . '/db-path.php', "initialize_managed_storage( $database_root ); + $this->fail( 'An invalid database path file was accepted.' ); + } catch ( RuntimeException $exception ) { + $this->assertStringContainsString( 'database path file is invalid', $exception->getMessage() ); + $this->assertStringNotContainsString( $database_root, $exception->getMessage() ); + } + } + + public function test_handles_a_database_path_file_that_cannot_be_loaded() { + $database_path_file = $this->create_temporary_directory_path() . '/missing-db-path.php'; + $storage = new WP_SQLite_Storage( dirname( $database_path_file ) ); + $read_database_path = Closure::bind( + function () use ( $database_path_file ) { + return $this->read_database_path( $database_path_file ); + }, + $storage, + WP_SQLite_Storage::class + ); + + try { + $read_database_path(); + $this->fail( 'A missing database path file was loaded.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'Failed to read the SQLite database path file.', $exception->getMessage() ); + $this->assertStringNotContainsString( $database_path_file, $exception->getMessage() ); + } + } + + public function test_recovers_a_missing_database_referenced_by_the_path_file() { + $database_root = $this->create_temporary_directory_path(); + $database_path = $database_root . '/.ht.0123456789abcdef0123456789abcdef/.ht.sqlite'; + $this->create_directory( $database_root ); + file_put_contents( + $database_root . '/db-path.php', + "assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertFileExists( $database_path ); + $this->assertSame( 0600, fileperms( $database_path ) & 0777 ); + $this->assert_protected_directory( $database_root ); + $this->assert_protected_directory( dirname( $database_path ) ); + } + + public function test_initializes_an_explicit_database_file() { + $database_root = $this->create_temporary_directory_path(); + $database_path = $database_root . '/custom/database.sqlite'; + $storage = new WP_SQLite_Storage( $database_root ); + + $this->assertSame( $database_path, $storage->initialize( $database_path ) ); + $this->assertFileExists( $database_path ); + $this->assertSame( 0, filesize( $database_path ) ); + $this->assertSame( 0600, fileperms( $database_path ) & 0777 ); + $this->assertFileDoesNotExist( $database_path . '.lock' ); + $this->assertFileDoesNotExist( $database_root . '/db-path.php' ); + $this->assert_protected_directory( dirname( $database_path ) ); + } + + public function test_initializes_an_in_memory_database_without_creating_files() { + $working_directory = $this->create_temporary_directory_path(); + $this->create_directory( $working_directory ); + $previous_working_directory = getcwd(); + $this->assertNotFalse( $previous_working_directory ); + $this->assertTrue( chdir( $working_directory ) ); + + try { + $storage = new WP_SQLite_Storage( $this->create_temporary_directory_path() ); + $this->assertSame( ':memory:', $storage->initialize( ':memory:' ) ); + } finally { + $this->assertTrue( chdir( $previous_working_directory ) ); + } + + $this->assertSame( array( '.', '..' ), scandir( $working_directory ) ); + } + + public function test_rejects_an_empty_database_path_without_creating_files() { + $working_directory = $this->create_temporary_directory_path(); + $this->create_directory( $working_directory ); + $previous_working_directory = getcwd(); + $this->assertNotFalse( $previous_working_directory ); + $this->assertTrue( chdir( $working_directory ) ); + + try { + $storage = new WP_SQLite_Storage( $this->create_temporary_directory_path() ); + $storage->initialize( '' ); + $this->fail( 'An empty database path was accepted.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'The SQLite database path is invalid.', $exception->getMessage() ); + } finally { + $this->assertTrue( chdir( $previous_working_directory ) ); + } + + $this->assertSame( array( '.', '..' ), scandir( $working_directory ) ); + } + + public function test_preserves_an_existing_explicit_database_file() { + $database_root = $this->create_temporary_directory_path(); + $database_path = $database_root . '/custom.sqlite'; + $storage = new WP_SQLite_Storage( $database_root ); + $this->create_sqlite_database( $database_path ); + chmod( $database_path, 0640 ); + + $this->assertSame( $database_path, $storage->initialize( $database_path ) ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); + $this->assertSame( 0640, fileperms( $database_path ) & 0777 ); + $this->assert_protected_directory( $database_root ); + } + + public function test_does_not_expose_the_database_path_when_initialization_fails() { + $database_root = $this->create_temporary_directory_path(); + $blocking_path = $database_root . '/blocking-file'; + $database_path = $blocking_path . '/.ht.secret/.ht.sqlite'; + $this->create_directory( $database_root ); + file_put_contents( $blocking_path, '' ); + + try { + $storage = new WP_SQLite_Storage( $database_root ); + $storage->initialize( $database_path ); + $this->fail( 'An inaccessible database path was initialized.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'Failed to create the SQLite database directory.', $exception->getMessage() ); + $this->assertStringNotContainsString( $database_path, $exception->getMessage() ); + } + } + + public function test_preserves_the_current_database_filename() { + $this->assert_legacy_database_is_preserved( '.ht.sqlite' ); + } + + public function test_preserves_the_older_database_filename() { + $this->assert_legacy_database_is_preserved( '.ht.sqlite.php' ); + } + + private function assert_legacy_database_is_preserved( $filename ) { + $database_root = $this->create_temporary_directory_path(); + $this->create_directory( $database_root ); + touch( $database_root . '/' . $filename ); + + $this->assertSame( $database_root . '/' . $filename, $this->initialize_managed_storage( $database_root ) ); + $this->assertFileDoesNotExist( $database_root . '/db-path.php' ); + } + + private function initialize_managed_storage( $database_root ) { + $storage = new WP_SQLite_Storage( $database_root ); + + return $storage->initialize(); + } + + private function create_temporary_directory_path() { + $path = tempnam( sys_get_temp_dir(), 'wp-sqlite-storage-' ); + $this->assertNotFalse( $path ); + $this->assertTrue( unlink( $path ) ); + $this->temporary_directories[] = $path; + + return $path; + } + + private function create_sqlite_database( $database_path ) { + $this->create_directory( dirname( $database_path ) ); + $connection = new PDO( 'sqlite:' . $database_path ); + $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $connection->exec( 'CREATE TABLE storage_test (value TEXT NOT NULL)' ); + $connection->exec( "INSERT INTO storage_test VALUES ('preserved')" ); + + return $connection; + } + + private function read_sqlite_value( $database_path ) { + $connection = new PDO( 'sqlite:' . $database_path ); + + return $connection->query( 'SELECT value FROM storage_test' )->fetchColumn(); + } + + private function create_directory( $directory ) { + if ( ! is_dir( $directory ) ) { + $this->assertTrue( mkdir( $directory, 0700, true ) ); + } + } + + private function assert_protected_directory( $directory ) { + clearstatcache( true, $directory ); + $this->assertSame( 0700, fileperms( $directory ) & 0777 ); + $this->assertSame( 'DENY FROM ALL', file_get_contents( $directory . '/.htaccess' ) ); + $this->assertSame( 0600, fileperms( $directory . '/.htaccess' ) & 0777 ); + $this->assertSame( 'assertSame( 0600, fileperms( $directory . '/index.php' ) & 0777 ); + } + + private function remove_directory( $directory ) { + if ( ! is_dir( $directory ) ) { + return; + } + + foreach ( scandir( $directory ) as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if ( is_dir( $path ) && ! is_link( $path ) ) { + $this->remove_directory( $path ); + } else { + unlink( $path ); + } + } + + rmdir( $directory ); + } +} From 24756f8c1c2b571bec5d2b879b32af0661e55cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 17 Aug 2026 15:01:31 +0200 Subject: [PATCH 2/2] Migrate legacy SQLite database storage Move fixed database files into randomized storage automatically before opening SQLite. Serialize concurrent requests, checkpoint WAL, refuse busy or ambiguous storage, and preserve legacy files when migration fails. --- .../sqlite/class-wp-sqlite-storage.php | 97 +++++++++-- tests/phpunit/WP_SQLite_Storage_Test.php | 162 +++++++++++++++++- 2 files changed, 234 insertions(+), 25 deletions(-) diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php index 7136b9be0..266184c34 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php @@ -3,6 +3,9 @@ /** * Manages the storage layout for the WordPress SQLite database. * + * Legacy database migration uses a PDO SQLite connection: + * phpcs:disable WordPress.DB.RestrictedClasses.mysql__PDO + * * Filesystem warnings are suppressed to avoid exposing database paths: * phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged */ @@ -34,6 +37,13 @@ class WP_SQLite_Storage { */ private $database_root; + /** + * Database busy timeout during legacy migration, in milliseconds. + * + * @var int + */ + private $migration_timeout = 10000; + /** * Create a SQLite storage manager. * @@ -47,7 +57,7 @@ public function __construct( ?string $database_root = null ) { * Initialize the SQLite database storage. * * Uses an explicit file path or ":memory:" as provided. Otherwise, initializes - * managed storage with a randomized path unless a legacy database already exists. + * managed storage with a randomized path and migrates legacy storage as needed. * * @param string|null $database_path Optional explicit database path. * @return string Absolute path to the SQLite database file, or ":memory:". @@ -74,14 +84,6 @@ public function initialize( ?string $database_path = null ): string { if ( @is_file( $database_path ) ) { return $database_path; } - } else { - $legacy_path = $this->database_root . self::DATABASE_FILENAME; - if ( ! @is_file( $legacy_path ) ) { - $legacy_path .= '.php'; - } - if ( @is_file( $legacy_path ) ) { - return $legacy_path; - } } // Initialize or repair the managed database under a lock. @@ -90,17 +92,20 @@ public function initialize( ?string $database_path = null ): string { try { // Publish a new database path. if ( ! @is_file( $db_path_file ) ) { - $legacy_path = $this->database_root . self::DATABASE_FILENAME; - if ( ! @is_file( $legacy_path ) ) { - $legacy_path .= '.php'; - } - if ( @is_file( $legacy_path ) ) { - return $legacy_path; - } $this->publish_database_path(); } $database_path = $this->read_database_path( $db_path_file ); + // Migrate from legacy ".ht.sqlite" and ".ht.sqlite.php" paths. + $legacy_path = $this->database_root . self::DATABASE_FILENAME; + if ( ! @is_file( $legacy_path ) ) { + $legacy_path .= '.php'; + } + if ( ! @is_file( $database_path ) && @is_file( $legacy_path ) ) { + $this->migrate_legacy_database( $legacy_path, $database_path ); + return $database_path; + } + // Initialize a new database. $this->ensure_database( $database_path ); return $database_path; @@ -128,6 +133,66 @@ private function ensure_database( string $database_path ) { } } + /** + * Move the legacy database to the intended database path. + * + * @param string $legacy_path Absolute legacy database path. + * @param string $database_path Absolute intended database path. + */ + private function migrate_legacy_database( string $legacy_path, string $database_path ) { + // Disable WAL so only the main database file needs to be moved. + try { + $this->ensure_protected_directory( dirname( $database_path ) ); + + $connection = new PDO( 'sqlite:' . $legacy_path ); + $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $connection->exec( 'PRAGMA busy_timeout = ' . $this->migration_timeout ); + + $deadline = microtime( true ) + ( $this->migration_timeout / 1000 ); + do { + try { + $journal_mode = $connection->query( 'PRAGMA journal_mode = DELETE' )->fetchColumn(); + if ( 'delete' === strtolower( (string) $journal_mode ) ) { + break; + } + } catch ( PDOException $exception ) { + $error_info = $connection->errorInfo(); + $sqlite_error_code = isset( $error_info[1] ) ? (int) $error_info[1] : 0; + $sqlite_busy = 5; + if ( ( $sqlite_error_code & 0xff ) !== $sqlite_busy ) { + throw $exception; + } + } + + if ( microtime( true ) >= $deadline ) { + throw new RuntimeException( 'Failed to disable WAL before migrating the SQLite database.' ); + } + usleep( 100 * 1000 ); // Wait 100 milliseconds. + } while ( true ); + + // Block other reads and writes before moving the database. + $connection->exec( 'BEGIN EXCLUSIVE' ); + } catch ( Throwable $exception ) { + throw new RuntimeException( 'Failed to prepare the SQLite database for migration.', 0, $exception ); + } + + /* + * Close the connection just before moving the database. SQLite considers + * renaming an open database undefined, and Windows generally prevents it. + * We cannot fully prevent race conditions, but this makes them unlikely. + * + * See: https://www.sqlite.org/howtocorrupt.html#unlink + */ + $connection = null; + + // Move only the main database file. WAL was disabled and an exclusive + // lock was acquired, so no valid sidecar files are expected. + if ( ! @rename( $legacy_path, $database_path ) ) { + throw new RuntimeException( 'Failed to move the SQLite database file.' ); + } + @chmod( $database_path, 0600 ); + } + /** * Read and validate the database path file. * diff --git a/tests/phpunit/WP_SQLite_Storage_Test.php b/tests/phpunit/WP_SQLite_Storage_Test.php index c8ecc9512..8753cf5d4 100644 --- a/tests/phpunit/WP_SQLite_Storage_Test.php +++ b/tests/phpunit/WP_SQLite_Storage_Test.php @@ -228,21 +228,113 @@ public function test_does_not_expose_the_database_path_when_initialization_fails } } - public function test_preserves_the_current_database_filename() { - $this->assert_legacy_database_is_preserved( '.ht.sqlite' ); + public function test_automatically_migrates_the_current_legacy_database() { + $this->assert_legacy_database_is_migrated( '.ht.sqlite' ); } - public function test_preserves_the_older_database_filename() { - $this->assert_legacy_database_is_preserved( '.ht.sqlite.php' ); + public function test_automatically_migrates_the_older_legacy_database() { + $this->assert_legacy_database_is_migrated( '.ht.sqlite.php' ); } - private function assert_legacy_database_is_preserved( $filename ) { + public function test_prefers_the_current_legacy_database() { $database_root = $this->create_temporary_directory_path(); - $this->create_directory( $database_root ); - touch( $database_root . '/' . $filename ); + $current_path = $database_root . '/.ht.sqlite'; + $older_path = $database_root . '/.ht.sqlite.php'; + $this->create_sqlite_database( $current_path ); + $this->create_sqlite_database( $older_path ); - $this->assertSame( $database_root . '/' . $filename, $this->initialize_managed_storage( $database_root ) ); - $this->assertFileDoesNotExist( $database_root . '/db-path.php' ); + $database_path = $this->initialize_managed_storage( $database_root ); + + $this->assertFileDoesNotExist( $current_path ); + $this->assertFileExists( $older_path ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); + } + + public function test_waits_for_existing_wal_connections_before_migrating() { + $database_root = $this->create_temporary_directory_path(); + $legacy_path = $database_root . '/.ht.sqlite'; + $connection = $this->create_sqlite_database( $legacy_path ); + $connection->query( 'PRAGMA journal_mode = WAL' ); + $connection = null; + + list( $process, $pipes ) = $this->open_temporary_database_connection( $legacy_path ); + try { + $database_path = $this->initialize_managed_storage( $database_root ); + } finally { + $process_result = $this->close_temporary_database_connection( $process, $pipes ); + } + + $this->assertSame( 0, $process_result['exit_code'] ); + $this->assertSame( '', $process_result['error'] ); + $this->assertFileDoesNotExist( $legacy_path ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); + } + + public function test_does_not_migrate_a_busy_legacy_database() { + $database_root = $this->create_temporary_directory_path(); + $legacy_path = $database_root . '/.ht.sqlite'; + $connection = $this->create_sqlite_database( $legacy_path ); + $connection->beginTransaction(); + $connection->exec( "UPDATE storage_test SET value = 'pending'" ); + $storage = new WP_SQLite_Storage( $database_root ); + $set_migration_timeout = Closure::bind( + function ( $migration_timeout ) { + $this->migration_timeout = $migration_timeout; + }, + $storage, + WP_SQLite_Storage::class + ); + $set_migration_timeout( 10 ); + + try { + $storage->initialize(); + $this->fail( 'A busy SQLite database was migrated.' ); + } catch ( RuntimeException $exception ) { + $this->assertStringContainsString( 'Failed to prepare the SQLite database for migration', $exception->getMessage() ); + $this->assertStringNotContainsString( $legacy_path, $exception->getMessage() ); + } finally { + $connection->rollBack(); + } + + $database_path = require $database_root . '/db-path.php'; + + $this->assertFileExists( $legacy_path ); + $this->assertFileDoesNotExist( $database_path ); + $this->assertSame( $database_path, $storage->initialize() ); + $this->assertFileDoesNotExist( $legacy_path ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); + } + + public function test_does_not_expose_database_paths_when_migration_fails() { + $database_root = $this->create_temporary_directory_path(); + $legacy_path = $database_root . '/.ht.sqlite'; + $database_path = $database_root . '/.ht.secret/.ht.sqlite'; + $this->create_sqlite_database( $legacy_path ); + $this->create_directory( $database_path ); + file_put_contents( $database_root . '/db-path.php', "initialize_managed_storage( $database_root ); + $this->fail( 'The database was migrated over a directory.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'Failed to move the SQLite database file.', $exception->getMessage() ); + $this->assertStringNotContainsString( $database_path, $exception->getMessage() ); + } + + $this->assertFileExists( $legacy_path ); + } + + private function assert_legacy_database_is_migrated( $filename ) { + $database_root = $this->create_temporary_directory_path(); + $legacy_path = $database_root . '/' . $filename; + $this->create_wal_sqlite_database_copy( $legacy_path ); + + $database_path = $this->initialize_managed_storage( $database_root ); + $stored_database_path = require $database_root . '/db-path.php'; + + $this->assertSame( $database_path, $stored_database_path ); + $this->assertFileDoesNotExist( $legacy_path ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); } private function initialize_managed_storage( $database_root ) { @@ -270,6 +362,58 @@ private function create_sqlite_database( $database_path ) { return $connection; } + private function create_wal_sqlite_database_copy( $database_path ) { + $source_directory = $this->create_temporary_directory_path(); + $source_path = $source_directory . '/source.sqlite'; + $this->create_directory( $source_directory ); + $this->create_directory( dirname( $database_path ) ); + + $connection = new PDO( 'sqlite:' . $source_path ); + $connection->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $this->assertSame( 'wal', $connection->query( 'PRAGMA journal_mode = WAL' )->fetchColumn() ); + $connection->exec( 'PRAGMA wal_autocheckpoint = 0' ); + $connection->exec( 'CREATE TABLE storage_test (value TEXT NOT NULL)' ); + $connection->exec( "INSERT INTO storage_test VALUES ('preserved')" ); + + $this->assertTrue( copy( $source_path, $database_path ) ); + $this->assertTrue( copy( $source_path . '-wal', $database_path . '-wal' ) ); + $connection = null; + } + + private function open_temporary_database_connection( $database_path ) { + $script = sprintf( + '$connection = new PDO(%s); $connection->query("SELECT value FROM storage_test")->fetchColumn(); fwrite(STDOUT, "ready\n"); fflush(STDOUT); usleep(250000);', + var_export( 'sqlite:' . $database_path, true ) + ); + $command = escapeshellarg( PHP_BINARY ) . ' -r ' . escapeshellarg( $script ); + $process = proc_open( + $command, + array( + array( 'pipe', 'r' ), + array( 'pipe', 'w' ), + array( 'pipe', 'w' ), + ), + $pipes + ); + + $this->assertIsResource( $process ); + fclose( $pipes[0] ); + $this->assertSame( "ready\n", fgets( $pipes[1] ) ); + + return array( $process, $pipes ); + } + + private function close_temporary_database_connection( $process, $pipes ) { + fclose( $pipes[1] ); + $error = stream_get_contents( $pipes[2] ); + fclose( $pipes[2] ); + + return array( + 'exit_code' => proc_close( $process ), + 'error' => $error, + ); + } + private function read_sqlite_value( $database_path ) { $connection = new PDO( 'sqlite:' . $database_path );