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..266184c34 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php @@ -0,0 +1,325 @@ +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 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:". + */ + 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; + } + } + + // 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 ) ) { + $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; + } 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 ); + } + } + + /** + * 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. + * + * @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..8753cf5d4 --- /dev/null +++ b/tests/phpunit/WP_SQLite_Storage_Test.php @@ -0,0 +1,458 @@ +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_automatically_migrates_the_current_legacy_database() { + $this->assert_legacy_database_is_migrated( '.ht.sqlite' ); + } + + public function test_automatically_migrates_the_older_legacy_database() { + $this->assert_legacy_database_is_migrated( '.ht.sqlite.php' ); + } + + public function test_prefers_the_current_legacy_database() { + $database_root = $this->create_temporary_directory_path(); + $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 ); + + $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 ) { + $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 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 ); + + 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 ); + } +}