diff --git a/packages/plugin-sqlite-database-integration/constants.php b/packages/plugin-sqlite-database-integration/constants.php index 47be3afac..371fdb712 100644 --- a/packages/plugin-sqlite-database-integration/constants.php +++ b/packages/plugin-sqlite-database-integration/constants.php @@ -13,21 +13,58 @@ } /** - * Notice: - * Your scripts have the permission to create directories or files on your server. - * If you write in your wp-config.php like below, we take these definitions. - * define('DB_DIR', '/full_path_to_the_database_directory/'); - * define('DB_FILE', 'database_file_name'); + * DB_PATH is the absolute database file path, or ":memory:" for an in-memory database. + * + * Conflicting deprecated constants trigger a warning. DB_PATH takes precedence. + * The database directory is also used for storage locks and must be writable by PHP. + * When not configured, the drop-in defines DB_PATH after initializing the storage. + * + * Example: define( 'DB_PATH', '/private/wordpress/database.sqlite' ); + */ +if ( 'sqlite' === DB_ENGINE && defined( 'DB_PATH' ) ) { + if ( ! is_string( DB_PATH ) ) { + throw new RuntimeException( 'DB_PATH must be a string.' ); + } + + $database_directory = ':memory:' === DB_PATH ? null : rtrim( dirname( DB_PATH ), '/' . DIRECTORY_SEPARATOR ); + + if ( defined( 'DB_DIR' ) && rtrim( (string) DB_DIR, '/' . DIRECTORY_SEPARATOR ) !== $database_directory ) { + trigger_error( 'DB_DIR conflicts with DB_PATH.', E_USER_WARNING ); + } + if ( defined( 'DB_FILE' ) && ( ':memory:' === DB_PATH || DB_FILE !== basename( DB_PATH ) ) ) { + trigger_error( 'DB_FILE conflicts with DB_PATH.', E_USER_WARNING ); + } + if ( defined( 'FQDBDIR' ) && rtrim( (string) FQDBDIR, '/' . DIRECTORY_SEPARATOR ) !== $database_directory ) { + trigger_error( 'FQDBDIR conflicts with DB_PATH.', E_USER_WARNING ); + } + if ( defined( 'FQDB' ) && FQDB !== DB_PATH ) { + trigger_error( 'FQDB conflicts with DB_PATH.', E_USER_WARNING ); + } + unset( $database_directory ); +} + +/** + * DB_DIR selects the database directory when DB_PATH is not configured. + * + * @deprecated 3.1.0 Define DB_PATH instead. + */ + +/** + * DB_FILE selects a filename inside DB_DIR or FQDBDIR. + * + * @deprecated 3.1.0 Define DB_PATH instead. */ /** * FQDBDIR is a directory where the sqlite database file is placed. - * If DB_DIR is defined, it is used as FQDBDIR. + * Defaults to the directory containing DB_PATH, or the legacy directory setting. * - * @deprecated 3.0.0 Define DB_DIR instead of overriding FQDBDIR. + * @deprecated 3.0.0 Define DB_PATH instead of overriding FQDBDIR. */ if ( ! defined( 'FQDBDIR' ) ) { - if ( defined( 'DB_DIR' ) ) { + if ( defined( 'DB_PATH' ) && is_string( DB_PATH ) && ':memory:' !== DB_PATH ) { + define( 'FQDBDIR', rtrim( dirname( DB_PATH ), '/\\' ) . '/' ); + } elseif ( ! defined( 'DB_PATH' ) && defined( 'DB_DIR' ) ) { define( 'FQDBDIR', rtrim( DB_DIR, '/\\' ) . '/' ); } elseif ( defined( 'WP_CONTENT_DIR' ) ) { define( 'FQDBDIR', WP_CONTENT_DIR . '/database/' ); @@ -39,12 +76,16 @@ /** * 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. + * Defaults to DB_PATH, or FQDBDIR combined with DB_FILE. Otherwise, the drop-in + * defines FQDB after resolving the secret database path. * - * @deprecated 3.0.0 Define DB_DIR and DB_FILE instead of overriding FQDB. + * @deprecated 3.0.0 Define DB_PATH instead of overriding FQDB. */ -if ( ! defined( 'FQDB' ) && defined( 'DB_FILE' ) ) { - define( 'FQDB', FQDBDIR . DB_FILE ); +if ( ! defined( 'FQDB' ) ) { + if ( defined( 'DB_PATH' ) ) { + define( 'FQDB', DB_PATH ); + } elseif ( defined( 'DB_FILE' ) ) { + // Avoid compile-time warnings for invalid constants on PHP 7.2. + define( 'FQDB', constant( 'FQDBDIR' ) . constant( 'DB_FILE' ) ); + } } diff --git a/packages/plugin-sqlite-database-integration/health-check.php b/packages/plugin-sqlite-database-integration/health-check.php index cf78ea659..0420c0f15 100644 --- a/packages/plugin-sqlite-database-integration/health-check.php +++ b/packages/plugin-sqlite-database-integration/health-check.php @@ -33,13 +33,15 @@ function sqlite_plugin_filter_debug_data( $info ) { $info['wp-database']['fields']['database_file'] = array( 'label' => __( 'Database file', 'sqlite-database-integration' ), - 'value' => FQDB, + 'value' => DB_PATH, 'private' => true, ); $info['wp-database']['fields']['database_size'] = array( 'label' => __( 'Database size', 'sqlite-database-integration' ), - 'value' => size_format( filesize( FQDB ) ), + 'value' => ':memory:' === DB_PATH + ? __( 'Not available', 'sqlite-database-integration' ) + : size_format( filesize( DB_PATH ) ), ); unset( $info['wp-database']['fields']['extension'] ); 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 17d0a2222..15ad52c53 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 @@ -447,7 +447,7 @@ public function db_connect( $allow_bail = true ) { $dbh = new WP_MySQL_On_SQLite( sprintf( 'mysql-on-sqlite:path=%s;dbname=%s', - str_replace( ';', ';;', FQDB ), + str_replace( ';', ';;', DB_PATH ), str_replace( ';', ';;', $this->dbname ) ), null, 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 a87f01a9e..3adaa68c4 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 @@ -5,8 +5,8 @@ * * The storage handles the path of the SQLite database: * - An explicit database path is used as is. - * - A managed database is stored under a randomized protected path. - * - A database in a legacy path is migrated to the managed storage. + * - A database with a secret path is stored in a randomized protected directory. + * - A database in a legacy path is migrated to a secret path. * * The storage implements a locking mechanism for initialization and maintenance. * Locking uses a dedicated empty SQLite database and a maintenance file: @@ -49,9 +49,10 @@ class WP_SQLite_Storage { private const TOKEN_BYTE_LENGTH = 16; /** - * Managed database root directory. + * Directory with the storage lock files and, for a secret path, the database. + * Null for an in-memory database. * - * @var string + * @var string|null */ private $database_root; @@ -65,21 +66,21 @@ class WP_SQLite_Storage { /** * Absolute path of the database path file. * - * @var string + * @var string|null */ private $database_path_file; /** * Absolute path of the SQLite locking database. * - * @var string + * @var string|null */ private $lock_path; /** * Absolute path of the storage maintenance marker. * - * @var string + * @var string|null */ private $maintenance_path; @@ -105,28 +106,55 @@ class WP_SQLite_Storage { private $database_lock_timeout = 10000; /** - * Create a SQLite storage manager. + * Create a storage manager for a database with a secret path. + * + * The database is stored in a randomized directory inside the root directory. + * Legacy databases in the root directory are migrated to it. + * + * @param string $root Root directory of the database storage. + * @return self The storage manager. + * @throws RuntimeException When the root directory is not an absolute path. + */ + public static function with_secret_path( string $root ): self { + if ( ! self::is_absolute_path( $root ) ) { + throw new RuntimeException( 'The SQLite database directory must be an absolute filesystem path.' ); + } + + $storage = new self(); + $storage->set_database_root( $root ); + return $storage; + } + + /** + * Create a storage manager for an explicit database path. * - * @param string|null $database_root Managed database root. Defaults to FQDBDIR. - * @param string|null $database_path Explicit database path or ":memory:". Managed storage is used when null. + * @param string $path Absolute database path or ":memory:". + * @return self The storage manager. * @throws RuntimeException When the database path is invalid. */ - public function __construct( ?string $database_root = null, ?string $database_path = null ) { - if ( '' === $database_path ) { - throw new RuntimeException( 'The SQLite database path is invalid.' ); + public static function with_explicit_path( string $path ): self { + if ( ':memory:' !== $path ) { + if ( ! self::is_absolute_path( $path ) ) { + throw new RuntimeException( 'The SQLite database path must be an absolute filesystem path or ":memory:".' ); + } + if ( is_dir( $path ) || in_array( substr( $path, -1 ), array( '/', DIRECTORY_SEPARATOR ), true ) ) { + throw new RuntimeException( 'The SQLite database path must point to a file, not a directory.' ); + } } - $this->database_root = rtrim( $database_root ?? FQDBDIR, '/\\' ) . '/'; - $this->database_path = $database_path; - $this->database_path_file = $this->database_root . self::DATABASE_PATH_FILENAME; - $this->lock_path = $this->database_root . self::LOCK_FILENAME; - $this->maintenance_path = $this->database_root . self::MAINTENANCE_FILENAME; + + $storage = new self(); + $storage->database_path = $path; + if ( ':memory:' !== $path ) { + $storage->set_database_root( dirname( $path ) ); + } + return $storage; } /** * Initialize the SQLite database storage. * * Uses an explicit file path or ":memory:" as configured. Otherwise, initializes - * managed storage with a randomized path and migrates legacy databases as needed. + * a secret randomized path and migrates legacy databases as needed. * * @return string Absolute path to the SQLite database file, or ":memory:". * @throws RuntimeException When the storage cannot be initialized. @@ -153,13 +181,13 @@ public function initialize(): string { return $this->database_path; } - // Initialized managed database path. + // Initialized secret database path. $database_path = $this->read_recorded_database_path(); if ( null !== $database_path && @is_file( $database_path ) ) { return $database_path; } - // Initialize or repair the managed storage under the storage lock. + // Initialize or repair the secret path under the storage lock. // Preserve a lock already held by this instance for a larger operation. $this->lock(); try { @@ -210,6 +238,11 @@ public function initialize(): string { * @throws RuntimeException When the lock cannot be acquired. */ public function lock(): void { + // An in-memory database has no files to lock. + if ( ':memory:' === $this->database_path ) { + return; + } + $was_locked = null !== $this->storage_lock_connection; // Serialize maintenance through the dedicated locking database. @@ -245,7 +278,7 @@ public function lock(): void { if ( null === $database_path ) { $database_path = $this->read_recorded_database_path(); - // The managed database may still be at a legacy path. + // The database may still be at a legacy path. if ( null === $database_path || ! @is_file( $database_path ) ) { $database_path = $this->database_root . self::DATABASE_FILENAME; } @@ -282,6 +315,46 @@ public function unlock(): void { } } + /** + * Create a storage manager. Use with_secret_path() or with_explicit_path(). + */ + private function __construct() { + } + + /** + * Set the storage root directory and the paths derived from it. + * + * @param string $database_root Storage root directory. + */ + private function set_database_root( string $database_root ): void { + $this->database_root = rtrim( $database_root, '/\\' ) . '/'; + $this->database_path_file = $this->database_root . self::DATABASE_PATH_FILENAME; + $this->lock_path = $this->database_root . self::LOCK_FILENAME; + $this->maintenance_path = $this->database_root . self::MAINTENANCE_FILENAME; + } + + /** + * Check whether a path is an absolute filesystem path. + * + * @param string $path Filesystem path. + * @return bool Whether the path is absolute. + */ + private static function is_absolute_path( string $path ): bool { + if ( '' === $path || false !== strpos( $path, "\0" ) ) { + return false; + } + + if ( '/' === DIRECTORY_SEPARATOR ) { + return '/' === $path[0]; + } else { + // Match Windows absolute path with a drive letter or UNC share. + return 1 === preg_match( + '~^(?:[a-zA-Z]:/|//[^/]+/[^/]+/)~', + str_replace( '\\', '/', $path ) + ); + } + } + /** * Wait until no maintenance process holds an active storage lock. */ @@ -373,7 +446,7 @@ function_exists( 'opcache_invalidate' ) } /** - * Move a legacy database into a managed path. + * Move a legacy database into a secret path. * * @param string $legacy_path Absolute path of the legacy database file. * @param string $database_path Absolute destination path. 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 0df921c8d..fb5de2b67 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/db.php @@ -8,47 +8,53 @@ */ require_once __DIR__ . '/../database/version.php'; -// Require the constants file. -require_once __DIR__ . '/../../constants.php'; +try { + // Require the constants file. + require_once __DIR__ . '/../../constants.php'; -// Bail early if DB_ENGINE is not defined as sqlite. -if ( ! defined( 'DB_ENGINE' ) || 'sqlite' !== DB_ENGINE ) { - return; -} + // Bail early if DB_ENGINE is not defined as sqlite. + if ( ! defined( 'DB_ENGINE' ) || 'sqlite' !== DB_ENGINE ) { + return; + } -if ( ! extension_loaded( 'pdo' ) ) { - wp_die( - new WP_Error( - 'pdo_not_loaded', - sprintf( - '

%1$s

%2$s

', - 'PHP PDO Extension is not loaded', - 'Your PHP installation appears to be missing the PDO extension which is required for this version of WordPress and the type of database you have specified.' - ) - ), - 'PHP PDO Extension is not loaded.' - ); -} + if ( ! extension_loaded( 'pdo' ) ) { + wp_die( + new WP_Error( + 'pdo_not_loaded', + sprintf( + '

%1$s

%2$s

', + 'PHP PDO Extension is not loaded', + 'Your PHP installation appears to be missing the PDO extension which is required for this version of WordPress and the type of database you have specified.' + ) + ), + 'PHP PDO Extension is not loaded.' + ); + } -if ( ! extension_loaded( 'pdo_sqlite' ) ) { - wp_die( - new WP_Error( - 'pdo_driver_not_loaded', - sprintf( - '

%1$s

%2$s

', - 'PDO Driver for SQLite is missing', - 'Your PHP installation appears not to have the right PDO drivers loaded. These are required for this version of WordPress and the type of database you have specified.' - ) - ), - 'PDO Driver for SQLite is missing.' - ); -} + if ( ! extension_loaded( 'pdo_sqlite' ) ) { + wp_die( + new WP_Error( + 'pdo_driver_not_loaded', + sprintf( + '

%1$s

%2$s

', + 'PDO Driver for SQLite is missing', + 'Your PHP installation appears not to have the right PDO drivers loaded. These are required for this version of WordPress and the type of database you have specified.' + ) + ), + 'PDO Driver for SQLite is missing.' + ); + } -require_once __DIR__ . '/class-wp-sqlite-storage.php'; + require_once __DIR__ . '/class-wp-sqlite-storage.php'; -try { - $database_storage = new WP_SQLite_Storage( FQDBDIR, defined( 'FQDB' ) ? FQDB : null ); - $database_path = $database_storage->initialize(); + if ( defined( 'DB_PATH' ) ) { + $database_storage = WP_SQLite_Storage::with_explicit_path( DB_PATH ); + } elseif ( defined( 'FQDB' ) ) { + $database_storage = WP_SQLite_Storage::with_explicit_path( FQDB ); + } else { + $database_storage = WP_SQLite_Storage::with_secret_path( FQDBDIR ); + } + $database_path = $database_storage->initialize(); } catch ( Throwable $exception ) { error_log( 'SQLite database error: ' . (string) $exception ); @@ -62,6 +68,10 @@ wp_die( htmlspecialchars( $exception->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ), 'SQLite database error', array( 'response' => 503 ) ); } +if ( ! defined( 'DB_PATH' ) ) { + define( 'DB_PATH', $database_path ); +} + if ( ! defined( 'FQDB' ) ) { define( 'FQDB', $database_path ); } diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php index da92b622f..7e6610fad 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php +++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php @@ -23,7 +23,7 @@ function sqlite_make_db_sqlite() { $translator = new WP_MySQL_On_SQLite( sprintf( 'mysql-on-sqlite:path=%s;dbname=%s', - str_replace( ';', ';;', FQDB ), + str_replace( ';', ';;', DB_PATH ), str_replace( ';', ';;', $wpdb->dbname ) ), null, diff --git a/tests/phpunit/WP_SQLite_Storage_Test.php b/tests/phpunit/WP_SQLite_Storage_Test.php index 9b2165047..8603081e5 100644 --- a/tests/phpunit/WP_SQLite_Storage_Test.php +++ b/tests/phpunit/WP_SQLite_Storage_Test.php @@ -23,7 +23,7 @@ public function test_creates_randomized_database_storage() { $umask = umask( 0777 ); try { - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); } finally { umask( $umask ); } @@ -54,11 +54,11 @@ public function test_creates_randomized_database_storage() { 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 ); + $first_path = $this->initialize_secret_path( $database_root ); $this->assertTrue( unlink( $database_root . '/.htaccess' ) ); $this->assertTrue( unlink( dirname( $first_path ) . '/index.php' ) ); - $second_path = $this->initialize_managed_storage( $database_root ); + $second_path = $this->initialize_secret_path( $database_root ); $this->assertSame( $first_path, $second_path ); $this->assertFileDoesNotExist( $database_root . '/.htaccess' ); @@ -72,7 +72,7 @@ public function test_initializes_custom_database_directory_without_wordpress_hel $database_root = $this->create_temporary_directory_path() . '/nested/database'; $plugin_path = WP_CONTENT_DIR . '/plugins/sqlite-database-integration'; $script = sprintf( - 'define("DB_DIR", %s); require %s; require %s; $storage = new WP_SQLite_Storage(); echo $storage->initialize();', + 'define("DB_DIR", %s); require %s; require %s; $storage = WP_SQLite_Storage::with_secret_path(FQDBDIR); echo $storage->initialize();', var_export( $database_root . $suffix, true ), var_export( $plugin_path . '/constants.php', true ), var_export( $plugin_path . '/wp-includes/sqlite/class-wp-sqlite-storage.php', true ) @@ -118,13 +118,598 @@ class WP_CLI { public static function error($message) { fwrite(STDERR, "Error: " $this->assertStringContainsString( 'SQLite database error: RuntimeException:', file_get_contents( $database_root . '/error.log' ) ); } + /** + * @dataProvider database_file_settings + */ + public function test_dropin_resolves_database_file_settings( $settings, $expected_path ) { + $directory = $this->create_temporary_directory(); + foreach ( $settings as $name => $value ) { + if ( 'DB_FILE' !== $name ) { + $settings[ $name ] = $directory . '/' . $value; + } + } + $settings['WP_CONTENT_DIR'] = $directory . '/content'; + $script = ' + $path = $wpdb->get_driver()->get_sqlite_pdo()->query("PRAGMA database_list")->fetch(PDO::FETCH_ASSOC)["file"]; + $wpdb->set_prefix("wp_"); + sqlite_make_db_sqlite(); + echo json_encode(array( + "path" => DB_PATH, + "connected_path" => $path, + "legacy_path" => FQDB, + "legacy_directory" => FQDBDIR, + "installed" => "wp_options" === $wpdb->get_var("SHOW TABLES LIKE \'wp_options\'") + )); + $wpdb->close(); + gc_collect_cycles(); + $database_storage->lock(); + $database_storage->unlock();'; + + $result = $this->close_process( ...$this->open_dropin_process( $settings, $script ) ); + + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $data = json_decode( $result['output'], true ); + $this->assertIsArray( $data ); + $this->assertTrue( $data['installed'] ); + $this->assertSame( $data['path'], $data['legacy_path'] ); + + $this->assertSame( realpath( $data['path'] ), $data['connected_path'] ); + if ( null === $expected_path ) { + $database_root = $settings['FQDBDIR'] ?? $settings['DB_DIR'] ?? $settings['WP_CONTENT_DIR'] . '/database'; + $this->assertSame( $data['path'], require $database_root . '/db-path.php' ); + } else { + $this->assertSame( $directory . '/' . $expected_path, $data['path'] ); + $this->assertFileDoesNotExist( dirname( $data['path'] ) . '/db-path.php' ); + } + + if ( isset( $settings['DB_PATH'] ) ) { + $this->assertFileExists( dirname( $data['path'] ) . '/.ht.sqlite.lock' ); + $this->assertFileDoesNotExist( $settings['WP_CONTENT_DIR'] ); + $this->assertSame( $settings['FQDBDIR'] ?? dirname( $data['path'] ) . '/', $data['legacy_directory'] ); + } + } + + public function database_file_settings() { + return array( + 'default secret path' => array( array(), null ), + 'legacy directory' => array( array( 'DB_DIR' => 'legacy-directory' ), null ), + 'legacy storage root' => array( array( 'FQDBDIR' => 'legacy-root' ), null ), + 'legacy filename' => array( array( 'DB_FILE' => 'legacy.sqlite' ), 'content/database/legacy.sqlite' ), + 'legacy explicit path' => array( array( 'FQDB' => 'legacy.sqlite' ), 'legacy.sqlite' ), + 'legacy directory/file' => array( + array( + 'DB_DIR' => 'legacy-directory', + 'DB_FILE' => 'legacy.sqlite', + ), + 'legacy-directory/legacy.sqlite', + ), + 'all legacy constants' => array( + array( + 'DB_DIR' => 'ignored-directory', + 'DB_FILE' => 'ignored.sqlite', + 'FQDBDIR' => 'legacy-root', + 'FQDB' => 'legacy.sqlite', + ), + 'legacy.sqlite', + ), + 'explicit path' => array( array( 'DB_PATH' => 'private/database;name.sqlite' ), 'private/database;name.sqlite' ), + 'matching DB_DIR' => array( + array( + 'DB_PATH' => 'database.sqlite', + 'DB_DIR' => '', + ), + 'database.sqlite', + ), + 'matching DB_FILE' => array( + array( + 'DB_PATH' => 'database.sqlite', + 'DB_FILE' => 'database.sqlite', + ), + 'database.sqlite', + ), + 'matching FQDBDIR' => array( + array( + 'DB_PATH' => 'database.sqlite', + 'FQDBDIR' => '', + ), + 'database.sqlite', + ), + 'matching FQDB' => array( + array( + 'DB_PATH' => 'database.sqlite', + 'FQDB' => 'database.sqlite', + ), + 'database.sqlite', + ), + 'all matching' => array( + array( + 'DB_PATH' => 'private/database.sqlite', + 'DB_DIR' => 'private', + 'DB_FILE' => 'database.sqlite', + 'FQDBDIR' => 'private/', + 'FQDB' => 'private/database.sqlite', + ), + 'private/database.sqlite', + ), + 'trailing separators' => array( + array( + 'DB_PATH' => 'private/database.sqlite', + 'DB_DIR' => 'private///', + 'FQDBDIR' => 'private///', + ), + 'private/database.sqlite', + ), + ); + } + + /** + * @dataProvider in_memory_database_settings + */ + public function test_dropin_uses_in_memory_db_path( $legacy_settings ) { + $directory = $this->create_temporary_directory(); + $settings = array( + 'DB_PATH' => ':memory:', + 'WP_CONTENT_DIR' => $directory . '/content', + ); + $script = ' + echo json_encode(array( + "path" => DB_PATH, + "connected_path" => $wpdb->get_driver()->get_sqlite_pdo()->query("PRAGMA database_list")->fetch(PDO::FETCH_ASSOC)["file"], + "legacy_path" => FQDB, + "legacy_directory" => FQDBDIR + ));'; + + $result = $this->close_process( ...$this->open_dropin_process( $settings + $legacy_settings, $script ) ); + + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $data = json_decode( $result['output'], true ); + $this->assertIsArray( $data ); + $this->assertSame( ':memory:', $data['path'] ); + $this->assertSame( '', $data['connected_path'] ); + $this->assertSame( ':memory:', $data['legacy_path'] ); + $this->assertSame( $directory . '/content/database/', $data['legacy_directory'] ); + $this->assertSame( array( '.', '..' ), scandir( $directory ) ); + } + + public function in_memory_database_settings() { + return array( + 'DB_PATH only' => array( array() ), + 'matching FQDB' => array( array( 'FQDB' => ':memory:' ) ), + ); + } + + /** + * @dataProvider conflicting_database_file_settings + */ + public function test_warns_about_conflicting_legacy_constants_and_uses_db_path( $settings, $conflicting_constants ) { + $settings += array( 'DB_PATH' => 'database.sqlite' ); + foreach ( array( 'constants.php', 'wp-includes/sqlite/db.php' ) as $entrypoint ) { + $directory = $this->create_temporary_directory(); + $resolved_settings = array( + 'DB_ENGINE' => 'sqlite', + 'WP_CONTENT_DIR' => $directory . '/content', + ); + $this->create_sqlite_database( $directory . '/legacy.sqlite' ); + foreach ( $settings as $name => $value ) { + $resolved_settings[ $name ] = ! is_string( $value ) || 'DB_FILE' === $name || ':memory:' === $value ? $value : $directory . '/' . $value; + } + $database_path = $resolved_settings['DB_PATH']; + if ( ':memory:' !== $database_path ) { + if ( ! is_dir( dirname( $database_path ) ) ) { + $this->assertTrue( mkdir( dirname( $database_path ), 0700, true ) ); + } + $this->create_sqlite_database( $database_path ); + } + $files_before = scandir( $directory ); + $before_dropin = '$table_prefix = "wp_"; $warnings = array(); set_error_handler(function($severity, $message) use (&$warnings) { + $warnings[] = array($severity, $message); return true; + });'; + $script = 'echo json_encode(array( + "warnings" => $warnings, + "path" => DB_PATH, + "connected_path" => isset($wpdb) ? $wpdb->get_driver()->get_sqlite_pdo()->query("PRAGMA database_list")->fetch(PDO::FETCH_ASSOC)["file"] : null + ));'; + if ( 'constants.php' === $entrypoint ) { + $prelude = sprintf( + 'foreach (%s as $name => $value) { define($name, $value); } require %s;', + var_export( $resolved_settings, true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/constants.php', true ) + ); + $result = $this->close_process( ...$this->open_process( $before_dropin . $prelude . $script ) ); + } else { + $result = $this->close_process( ...$this->open_dropin_process( $resolved_settings, $script, $before_dropin ) ); + } + + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $data = json_decode( $result['output'], true ); + $this->assertIsArray( $data ); + $expected_warnings = array(); + foreach ( (array) $conflicting_constants as $name ) { + if ( in_array( $name, array( 'DB_DIR', 'FQDBDIR' ), true ) && is_array( $resolved_settings[ $name ] ) ) { + $expected_warnings[] = array( PHP_VERSION_ID < 80000 ? E_NOTICE : E_WARNING, 'Array to string conversion' ); + } + $expected_warnings[] = array( E_USER_WARNING, $name . ' conflicts with DB_PATH.' ); + } + $this->assertSame( $expected_warnings, $data['warnings'] ); + $this->assertSame( $database_path, $data['path'] ); + if ( 'constants.php' !== $entrypoint ) { + $this->assertSame( ':memory:' === $database_path ? '' : realpath( $database_path ), $data['connected_path'] ); + } + if ( ':memory:' !== $database_path ) { + $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); + } + $this->assertSame( 'preserved', $this->read_sqlite_value( $directory . '/legacy.sqlite' ) ); + $this->assertFileDoesNotExist( $resolved_settings['WP_CONTENT_DIR'] ); + if ( 'constants.php' === $entrypoint || ':memory:' === $database_path ) { + $this->assertSame( $files_before, scandir( $directory ) ); + } + } + } + + public function conflicting_database_file_settings() { + $settings = array( + 'different DB_DIR' => array( array( 'DB_DIR' => 'legacy/' ), 'DB_DIR' ), + 'different DB_FILE' => array( array( 'DB_FILE' => 'legacy.sqlite' ), 'DB_FILE' ), + 'different FQDBDIR' => array( array( 'FQDBDIR' => 'legacy/' ), 'FQDBDIR' ), + 'different FQDB' => array( array( 'FQDB' => 'legacy.sqlite' ), 'FQDB' ), + 'directory prefix' => array( + array( + 'DB_PATH' => 'private/database.sqlite', + 'DB_DIR' => 'private-other', + ), + 'DB_DIR', + ), + 'all different' => array( + array( + 'DB_DIR' => 'legacy/', + 'DB_FILE' => 'legacy.sqlite', + 'FQDB' => 'legacy.sqlite', + 'FQDBDIR' => 'legacy/', + ), + array( 'DB_DIR', 'DB_FILE', 'FQDBDIR', 'FQDB' ), + ), + ); + foreach ( array( 'DB_DIR', 'DB_FILE', 'FQDBDIR', 'FQDB' ) as $name ) { + $settings[ 'in-memory with ' . $name ] = array( + array( + 'DB_PATH' => ':memory:', + $name => 'database.sqlite', + ), + $name, + ); + foreach ( array( null, false, 123, array() ) as $value ) { + $settings[ $name . ' with ' . gettype( $value ) ] = array( array( $name => $value ), $name ); + } + } + $settings['in-memory with invalid DB_DIR'] = array( + array( + 'DB_PATH' => ':memory:', + 'DB_DIR' => array(), + ), + 'DB_DIR', + ); + return $settings; + } + + /** + * @dataProvider database_constant_path_comparisons + */ + public function test_compares_database_constants_without_creating_files( $settings, $expected_warning ) { + $script = sprintf( + 'define("DB_ENGINE", "sqlite"); foreach (%s as $name => $value) { define($name, $value); } + set_error_handler(function($severity, $message) { + if (E_USER_WARNING !== $severity) { return false; } echo $message; return true; + }); require %s;', + var_export( $settings, true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/constants.php', true ) + ); + $this->assert_creates_no_files( + function () use ( $script, $expected_warning ) { + $result = $this->close_process( ...$this->open_process( $script ) ); + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $this->assertSame( $expected_warning, $result['output'] ); + } + ); + } + + public function database_constant_path_comparisons() { + $root = '/' === DIRECTORY_SEPARATOR ? '/' : 'C:/'; + $cases = array( + 'root directory' => array( + array( + 'DB_PATH' => $root . 'database.sqlite', + 'DB_DIR' => $root, + ), + '', + ), + 'root with trailing separators' => array( + array( + 'DB_PATH' => $root . 'database.sqlite', + 'FQDBDIR' => $root . '//', + ), + '', + ), + 'empty directory' => array( + array( + 'DB_PATH' => $root . 'database.sqlite', + 'DB_DIR' => '', + ), + '/' === DIRECTORY_SEPARATOR ? '' : 'DB_DIR conflicts with DB_PATH.', + ), + 'missing directory' => array( + array( + 'DB_PATH' => $root . 'missing/database.sqlite', + 'DB_DIR' => $root . 'missing/', + 'FQDB' => $root . 'missing/database.sqlite', + ), + '', + ), + ); + if ( '\\' === DIRECTORY_SEPARATOR ) { + $cases['different Windows separators'] = array( + array( + 'DB_PATH' => 'C:\\missing\\database.sqlite', + 'DB_DIR' => 'C:/missing/', + 'FQDB' => 'C:/missing/database.sqlite', + ), + 'DB_DIR conflicts with DB_PATH.FQDB conflicts with DB_PATH.', + ); + $cases['Windows drive separator'] = array( + array( + 'DB_PATH' => 'C:/database.sqlite', + 'DB_DIR' => 'C:', + ), + '', + ); + $cases['different UNC separators'] = array( + array( + 'DB_PATH' => '\\\\server\\share\\database.sqlite', + 'FQDBDIR' => '//server/share/', + ), + 'FQDBDIR conflicts with DB_PATH.', + ); + } else { + $cases['Unix backslash is not a separator'] = array( + array( + 'DB_PATH' => '/missing/database.sqlite', + 'DB_DIR' => '/missing\\', + ), + 'DB_DIR conflicts with DB_PATH.', + ); + $cases['Unix path bytes'] = array( + array( + 'DB_PATH' => "/missing/data;quo'te-ž.sqlite ", + 'DB_FILE' => "data;quo'te-ž.sqlite ", + ), + '', + ); + } + return $cases; + } + + /** + * @dataProvider absolute_database_paths + */ + public function test_validates_absolute_database_paths_without_wordpress_helpers( $database_path ) { + $script = sprintf( + 'define("ABSPATH", %s); define("DB_PATH", %s); require %s; require %s; WP_SQLite_Storage::with_explicit_path(DB_PATH); echo FQDB;', + var_export( ABSPATH, true ), + var_export( $database_path, true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/constants.php', true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php', true ) + ); + + $this->assert_creates_no_files( + function () use ( $script, $database_path ) { + $result = $this->close_process( ...$this->open_process( $script ) ); + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $this->assertSame( $database_path, $result['output'] ); + } + ); + } + + public function absolute_database_paths() { + $root = '/' === DIRECTORY_SEPARATOR ? '/missing-directory' : 'C:/missing-directory'; + $paths = array( + 'in-memory database' => array( ':memory:' ), + 'missing file' => array( $root . '/database.sqlite' ), + 'path bytes' => array( $root . "/data;quo'te-ž.sqlite " ), + 'forward slash UNC' => array( '//server/share/database.sqlite' ), + ); + if ( '\\' === DIRECTORY_SEPARATOR ) { + $paths['Windows drive'] = array( 'C:\\missing-directory\\database.sqlite' ); + $paths['Windows UNC'] = array( '\\\\server\\share\\database.sqlite' ); + } else { + $paths['filename ending in a backslash'] = array( $root . '/database.sqlite\\' ); + } + return $paths; + } + + /** + * @dataProvider directory_database_paths + */ + public function test_rejects_directory_database_paths_without_creating_files( $suffix ) { + $directory = $this->create_temporary_directory(); + $this->assertTrue( mkdir( $directory . '/existing' ) ); + + try { + WP_SQLite_Storage::with_explicit_path( $directory . $suffix ); + $this->fail( 'A directory was accepted as a database file path.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'The SQLite database path must point to a file, not a directory.', $exception->getMessage() ); + } + + $this->assertSame( array( '.', '..', 'existing' ), scandir( $directory ) ); + $this->assertSame( array( '.', '..' ), scandir( $directory . '/existing' ) ); + } + + public function directory_database_paths() { + $paths = array( + 'existing directory' => array( '/existing' ), + 'existing directory with separator' => array( '/existing/' ), + 'missing directory with separator' => array( '/missing/' ), + 'missing nested directory' => array( '/missing/nested/' ), + ); + if ( '\\' === DIRECTORY_SEPARATOR ) { + $paths['Windows existing directory'] = array( '\\existing\\' ); + $paths['Windows missing directory'] = array( '\\missing\\' ); + } + return $paths; + } + + /** + * @dataProvider invalid_database_paths + */ + public function test_invalid_db_path_does_not_fall_back_to_legacy_database( $database_path ) { + $directory = $this->create_temporary_directory(); + $legacy_path = $directory . '/content/database/.ht.sqlite'; + $this->assertTrue( mkdir( $directory . '/content/database', 0777, true ) ); + $this->assertTrue( mkdir( $directory . '/working' ) ); + $this->create_sqlite_database( $legacy_path ); + $script = sprintf( + 'define("WP_CLI", true); define("DB_ENGINE", "sqlite"); define("DB_PATH", %s); define("WP_CONTENT_DIR", %s); + class WP_CLI { public static function error($message) { fwrite(STDERR, "Error: " . $message); exit(1); } } + chdir(%s); ini_set("error_log", %s); require %s;', + var_export( $database_path, true ), + var_export( $directory . '/content', true ), + var_export( $directory . '/working', true ), + var_export( $directory . '/error.log', true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/db.php', true ) + ); + + $result = $this->close_process( ...$this->open_process( $script ) ); + $message = ! is_string( $database_path ) + ? 'DB_PATH must be a string.' + : 'The SQLite database path must be an absolute filesystem path or ":memory:".'; + $this->assertSame( 1, $result['exit_code'] ); + $this->assertSame( '', $result['output'] ); + $this->assertSame( 'Error: ' . $message, $result['error'] ); + $this->assertSame( 'preserved', $this->read_sqlite_value( $legacy_path ) ); + $this->assertSame( array( '.', '..', 'content', 'error.log', 'working' ), scandir( $directory ) ); + $this->assertSame( array( '.', '..' ), scandir( $directory . '/working' ) ); + $this->assertSame( array( '.', '..', 'database' ), scandir( $directory . '/content' ) ); + $this->assertSame( array( '.', '..', '.ht.sqlite' ), scandir( $directory . '/content/database' ) ); + } + + public function invalid_database_paths() { + return $this->invalid_database_path_types() + $this->invalid_explicit_database_paths(); + } + + public function invalid_explicit_database_paths() { + $paths = array( + 'empty string' => array( '' ), + 'relative filename' => array( 'database.sqlite' ), + 'current directory' => array( './database.sqlite' ), + 'parent directory' => array( '../database.sqlite' ), + 'relative subdirectory' => array( 'data/database.sqlite' ), + 'Windows relative path' => array( 'data\\database.sqlite' ), + 'Windows drive-relative' => array( 'C:database.sqlite' ), + 'Windows current drive' => array( '\\database.sqlite' ), + 'file URI' => array( 'file:///database.sqlite' ), + 'stream wrapper' => array( 'php://memory' ), + 'invalid in-memory path' => array( ':memory:extra' ), + 'null byte' => array( "/database\0.sqlite" ), + ); + if ( '/' === DIRECTORY_SEPARATOR ) { + $paths['Windows drive with forward slashes'] = array( 'C:/data/database.sqlite' ); + $paths['Windows drive with backslashes'] = array( 'C:\\data\\database.sqlite' ); + $paths['Windows UNC'] = array( '\\\\server\\share\\database.sqlite' ); + } else { + $paths['Windows current drive with forward slash'] = array( '/database.sqlite' ); + $paths['incomplete UNC path'] = array( '//server/database.sqlite' ); + } + return $paths; + } + + /** + * @dataProvider invalid_database_path_types + */ + public function test_constants_reject_invalid_db_path_types_before_deriving_legacy_constants( $database_path ) { + $script = sprintf( + 'define("DB_ENGINE", "sqlite"); define("DB_PATH", %s); + try { require %s; } catch (RuntimeException $exception) { echo $exception->getMessage(); } + if (defined("FQDB") || defined("FQDBDIR")) { exit(1); }', + var_export( $database_path, true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/constants.php', true ) + ); + $result = $this->close_process( ...$this->open_process( $script ) ); + + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $this->assertSame( 'DB_PATH must be a string.', $result['output'] ); + } + + public function invalid_database_path_types() { + return array( + 'null' => array( null ), + 'false' => array( false ), + 'true' => array( true ), + 'integer' => array( 123 ), + 'float' => array( 1.5 ), + 'array' => array( array() ), + ); + } + + /** + * @dataProvider legacy_relative_database_paths + */ + public function test_dropin_rejects_legacy_relative_database_paths( $settings, $message ) { + $directory = $this->create_temporary_directory(); + $script = sprintf( + 'define("WP_CLI", true); define("DB_ENGINE", "sqlite"); define("WP_CONTENT_DIR", %s); + foreach (%s as $name => $value) { define($name, $value); } + class WP_CLI { public static function error($message) { fwrite(STDERR, "Error: " . $message); exit(1); } } + chdir(%s); ini_set("error_log", %s); require %s;', + var_export( $directory . '/content', true ), + var_export( $settings, true ), + var_export( $directory, true ), + var_export( $directory . '/error.log', true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/db.php', true ) + ); + $result = $this->close_process( ...$this->open_process( $script ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertSame( '', $result['output'] ); + $this->assertSame( 'Error: ' . $message, $result['error'] ); + $this->assertSame( array( '.', '..', 'error.log' ), scandir( $directory ) ); + } + + public function legacy_relative_database_paths() { + $path_error = 'The SQLite database path must be an absolute filesystem path or ":memory:".'; + $directory_error = 'The SQLite database directory must be an absolute filesystem path.'; + return array( + 'FQDB' => array( array( 'FQDB' => 'legacy/database.sqlite' ), $path_error ), + 'DB_DIR and DB_FILE' => array( + array( + 'DB_DIR' => 'legacy', + 'DB_FILE' => 'database.sqlite', + ), + $path_error, + ), + 'FQDBDIR and DB_FILE' => array( + array( + 'FQDBDIR' => 'legacy/', + 'DB_FILE' => 'database.sqlite', + ), + $path_error, + ), + 'DB_DIR' => array( array( 'DB_DIR' => 'legacy' ), $directory_error ), + 'FQDBDIR' => array( array( 'FQDBDIR' => 'legacy/' ), $directory_error ), + ); + } + 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 ); + $database_path = $this->initialize_secret_path( $database_root ); $this->assertTrue( chmod( $database_root, 0500 ) ); try { - $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); } finally { $this->assertTrue( chmod( $database_root, 0700 ) ); } @@ -132,11 +717,11 @@ public function test_reuses_initialized_storage_with_read_only_database_root() { public function test_skips_the_inactive_locking_database() { $database_root = $this->create_temporary_directory_path(); - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); // Opening this invalid database would make initialization fail. file_put_contents( $database_root . '/.ht.sqlite.lock', str_repeat( 'x', 4096 ) ); - $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); } public function test_initializes_one_database_for_concurrent_requests() { @@ -162,8 +747,8 @@ public function test_initializes_one_database_for_concurrent_requests() { public function test_waits_for_storage_maintenance_before_initializing() { $database_root = $this->create_temporary_directory_path(); - $database_path = $this->initialize_managed_storage( $database_root ); - $storage = new WP_SQLite_Storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $storage->lock(); list( $process, $pipes ) = $this->open_storage_process( $database_root, 'echo $storage->initialize();' ); @@ -179,7 +764,7 @@ public function test_waits_for_storage_maintenance_before_initializing() { public function test_initializes_the_storage_while_holding_the_lock() { $database_root = $this->create_temporary_directory_path(); - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $storage->lock(); // The lock held by this instance must not block its own initialization. @@ -204,16 +789,16 @@ public function test_initializes_the_storage_while_holding_the_lock() { $this->assertSame( 0, $result['exit_code'] ); $this->assertSame( '', $result['error'] ); $this->assertSame( 'locked', $result['output'] ); - $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); } public function test_follows_a_moved_database_directory() { $original_root = $this->create_temporary_directory_path(); $moved_root = $this->create_temporary_directory_path(); - $original_path = $this->initialize_managed_storage( $original_root ); + $original_path = $this->initialize_secret_path( $original_root ); $this->assertTrue( rename( $original_root, $moved_root ) ); - $moved_path = $this->initialize_managed_storage( $moved_root ); + $moved_path = $this->initialize_secret_path( $moved_root ); $this->assertSame( $moved_root . substr( $original_path, strlen( $original_root ) ), $moved_path ); $this->assertFileExists( $moved_path ); @@ -224,7 +809,7 @@ public function test_recovers_an_interrupted_database_path_write() { $database_root = $this->create_temporary_directory(); file_put_contents( $database_root . '/tmp.db-path.php', 'interrupted write' ); - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); $this->assertFileExists( $database_path ); $this->assertFileExists( $database_root . '/db-path.php' ); @@ -236,7 +821,7 @@ public function test_rejects_a_database_path_file_that_does_not_return_a_path() file_put_contents( $database_root . '/db-path.php', "initialize_managed_storage( $database_root ); + $this->initialize_secret_path( $database_root ); $this->fail( 'An invalid database path file was accepted.' ); } catch ( RuntimeException $exception ) { $this->assertStringContainsString( 'database path file is invalid', $exception->getMessage() ); @@ -249,7 +834,7 @@ public function test_handles_a_database_path_file_that_cannot_be_loaded() { file_put_contents( $database_root . '/db-path.php', "initialize_managed_storage( $database_root ); + $this->initialize_secret_path( $database_root ); $this->fail( 'An unreadable database path file was loaded.' ); } catch ( RuntimeException $exception ) { $this->assertSame( 'Failed to read the SQLite database path file.', $exception->getMessage() ); @@ -265,7 +850,7 @@ public function test_recovers_a_missing_database_referenced_by_the_path_file() { "assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); $this->assertFileExists( $database_path ); $this->assertSame( 0600, fileperms( $database_path ) & 0777 ); $this->assert_protected_directory( $database_root ); @@ -275,45 +860,75 @@ public function test_recovers_a_missing_database_referenced_by_the_path_file() { 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, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $this->assertSame( $database_path, $storage->initialize() ); $this->assertFileExists( $database_path ); $this->assertSame( 0, filesize( $database_path ) ); $this->assertSame( 0600, fileperms( $database_path ) & 0777 ); $this->assertFileDoesNotExist( $database_root . '/db-path.php' ); - $this->assertFileDoesNotExist( $database_root . '/.ht.sqlite.lock' ); + $this->assertFileDoesNotExist( dirname( $database_path ) . '/.ht.sqlite.lock' ); $this->assert_protected_directory( dirname( $database_path ) ); } - public function test_initializes_an_in_memory_database_without_creating_files() { + public function test_uses_an_in_memory_database_without_creating_files() { + $script = sprintf( + 'require %s; $storage = WP_SQLite_Storage::with_explicit_path(":memory:"); echo $storage->initialize(); $storage->lock(); $storage->unlock();', + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php', true ) + ); + $this->assert_creates_no_files( - function () { - $storage = new WP_SQLite_Storage( $this->create_temporary_directory_path(), ':memory:' ); - $this->assertSame( ':memory:', $storage->initialize() ); + function () use ( $script ) { + $result = $this->close_process( ...$this->open_process( $script ) ); + $this->assertSame( 0, $result['exit_code'], $result['error'] ); + $this->assertSame( '', $result['error'] ); + $this->assertSame( ':memory:', $result['output'] ); + } + ); + } + + /** + * @dataProvider invalid_explicit_database_paths + */ + public function test_rejects_invalid_explicit_database_paths_without_creating_files( $database_path ) { + $this->assert_creates_no_files( + function () use ( $database_path ) { + try { + WP_SQLite_Storage::with_explicit_path( $database_path ); + $this->fail( 'An invalid database path was accepted.' ); + } catch ( RuntimeException $exception ) { + $this->assertSame( 'The SQLite database path must be an absolute filesystem path or ":memory:".', $exception->getMessage() ); + } } ); } - public function test_rejects_an_empty_database_path_without_creating_files() { + /** + * @dataProvider invalid_secret_path_roots + */ + public function test_rejects_invalid_secret_path_roots_without_creating_files( $root ) { $this->assert_creates_no_files( - function () { + function () use ( $root ) { try { - new WP_SQLite_Storage( $this->create_temporary_directory_path(), '' ); - $this->fail( 'An empty database path was accepted.' ); + WP_SQLite_Storage::with_secret_path( $root ); + $this->fail( 'An invalid database directory was accepted.' ); } catch ( RuntimeException $exception ) { - $this->assertSame( 'The SQLite database path is invalid.', $exception->getMessage() ); + $this->assertSame( 'The SQLite database directory must be an absolute filesystem path.', $exception->getMessage() ); } } ); } + public function invalid_secret_path_roots() { + return array( 'in-memory database' => array( ':memory:' ) ) + $this->invalid_explicit_database_paths(); + } + public function test_preserves_an_existing_explicit_database_file() { $database_root = $this->create_temporary_directory(); $database_path = $database_root . '/custom.sqlite'; $this->create_sqlite_database( $database_path ); chmod( $database_path, 0640 ); - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $this->assertSame( $database_path, $storage->initialize() ); $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); @@ -328,7 +943,7 @@ public function test_does_not_expose_the_database_path_when_initialization_fails file_put_contents( $blocking_path, '' ); try { - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $storage->initialize(); $this->fail( 'An inaccessible database path was initialized.' ); } catch ( RuntimeException $exception ) { @@ -352,7 +967,7 @@ public function test_prefers_the_current_legacy_database() { $this->create_sqlite_database( $current_path ); $this->create_sqlite_database( $older_path ); - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); $this->assertFileDoesNotExist( $current_path ); $this->assertFileExists( $older_path ); @@ -366,7 +981,7 @@ public function test_waits_for_existing_wal_connections_before_migrating() { list( $process, $pipes ) = $this->open_database_connection_process( $legacy_path ); try { - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); } finally { $result = $this->close_process( $process, $pipes ); } @@ -387,7 +1002,7 @@ public function test_keeps_the_legacy_database_when_migration_fails() { file_put_contents( $database_root . '/db-path.php', "initialize_managed_storage( $database_root ); + $this->initialize_secret_path( $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() ); @@ -400,7 +1015,7 @@ public function test_keeps_the_legacy_database_when_migration_fails() { // The migration succeeds once the obstacle is removed. $this->assertTrue( rmdir( $database_path ) ); - $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); $this->assertFileDoesNotExist( $legacy_path ); $this->assertSame( 'preserved', $this->read_sqlite_value( $database_path ) ); } @@ -411,7 +1026,7 @@ public function test_does_not_migrate_an_invalid_legacy_database() { file_put_contents( $legacy_path, str_repeat( 'x', 4096 ) ); try { - $this->initialize_managed_storage( $database_root ); + $this->initialize_secret_path( $database_root ); $this->fail( 'An invalid legacy database was migrated.' ); } catch ( RuntimeException $exception ) { $this->assertSame( 'Failed to lock the SQLite database.', $exception->getMessage() ); @@ -425,9 +1040,9 @@ public function test_does_not_migrate_an_invalid_legacy_database() { public function test_reports_when_the_migrated_database_cannot_be_locked() { $database_root = $this->create_temporary_directory(); $legacy_path = $database_root . '/.ht.sqlite'; - $database_path = $database_root . '/managed/.ht.sqlite'; + $database_path = $database_root . '/secret/.ht.sqlite'; file_put_contents( $legacy_path, str_repeat( 'x', 4096 ) ); - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $move = Closure::bind( function () use ( $legacy_path, $database_path ) { $this->move_legacy_database( $legacy_path, $database_path ); @@ -452,7 +1067,7 @@ public function test_locks_and_unlocks_the_storage() { $database_root = $this->create_temporary_directory(); $lock_path = $database_root . '/.ht.sqlite.lock'; $maintenance_path = $database_root . '/.ht.sqlite.maintenance'; - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $storage->lock(); @@ -470,7 +1085,7 @@ public function test_locks_and_unlocks_the_storage() { public function test_waits_for_another_process_to_unlock() { $database_root = $this->create_temporary_directory(); - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $storage->lock(); list( $process, $pipes ) = $this->open_storage_process( $database_root, '$storage->lock(); echo "locked";' ); @@ -486,8 +1101,8 @@ public function test_waits_for_another_process_to_unlock() { public function test_does_not_acquire_a_busy_storage_lock() { $database_root = $this->create_temporary_directory(); - $owner = new WP_SQLite_Storage( $database_root ); - $storage = new WP_SQLite_Storage( $database_root ); + $owner = WP_SQLite_Storage::with_secret_path( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $owner->lock(); $this->set_storage_property( $storage, 'database_lock_timeout', 10 ); @@ -526,20 +1141,20 @@ public function test_unlocks_the_storage_when_the_owner_dies() { // The process released its SQLite transaction automatically. The next // request can remove its stale marker and initialize the storage. - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $this->assertFileExists( $storage->initialize() ); $this->assertFileDoesNotExist( $database_root . '/.ht.sqlite.maintenance' ); } public function test_clears_a_stale_marker_without_a_locking_database() { $database_root = $this->create_temporary_directory(); - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); $this->assertTrue( unlink( $database_root . '/.ht.sqlite.lock' ) ); file_put_contents( $database_root . '/.ht.sqlite.maintenance', '' ); $umask = umask( 0000 ); try { - $this->assertSame( $database_path, $this->initialize_managed_storage( $database_root ) ); + $this->assertSame( $database_path, $this->initialize_secret_path( $database_root ) ); } finally { umask( $umask ); } @@ -551,7 +1166,7 @@ public function test_clears_a_stale_marker_without_a_locking_database() { public function test_fails_when_the_lock_cannot_be_created() { $database_root = $this->create_temporary_directory(); - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); $storage->initialize(); $this->assertTrue( unlink( $database_root . '/.ht.sqlite.lock' ) ); $this->make_read_only( $database_root ); @@ -571,7 +1186,7 @@ public function test_releases_the_lock_when_the_maintenance_marker_cannot_be_cre $database_root = $this->create_temporary_directory(); $maintenance_path = $database_root . '/.ht.sqlite.maintenance'; $this->assertTrue( mkdir( $maintenance_path ) ); - $storage = new WP_SQLite_Storage( $database_root ); + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); try { $storage->lock(); @@ -592,7 +1207,7 @@ public function test_blocks_database_access_while_locked() { $this->assertSame( 'wal', $connection->query( 'PRAGMA journal_mode = WAL' )->fetchColumn() ); $connection = null; - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $storage->lock(); @@ -613,7 +1228,7 @@ public function test_ignores_a_legacy_database_for_an_explicit_path() { $database_path = $database_root . '/database.sqlite'; $legacy_path = $database_root . '/.ht.sqlite'; file_put_contents( $legacy_path, str_repeat( 'x', 4096 ) ); - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $storage->lock(); $storage->unlock(); @@ -628,7 +1243,7 @@ public function test_waits_for_an_active_wal_connection() { $database_root = $this->create_temporary_directory(); $database_path = $database_root . '/database.sqlite'; $this->create_wal_sqlite_database_copy( $database_path ); - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); list( $process, $pipes ) = $this->open_database_connection_process( $database_path ); $started = microtime( true ); @@ -653,7 +1268,7 @@ public function test_does_not_lock_an_invalid_database() { file_put_contents( $database_path, str_repeat( 'x', 4096 ) ); try { - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $storage->lock(); $this->fail( 'An invalid database was locked.' ); } catch ( RuntimeException $exception ) { @@ -672,7 +1287,7 @@ public function test_does_not_create_a_missing_database() { $database_root = $this->create_temporary_directory(); $database_path = $database_root . '/missing.sqlite'; - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $lock_database = Closure::bind( function () use ( $database_path ) { return $this->lock_database( $database_path ); @@ -697,7 +1312,7 @@ public function test_does_not_lock_a_busy_database() { $connection = $this->create_sqlite_database( $database_path ); $connection->beginTransaction(); $connection->exec( "UPDATE storage_test SET value = 'pending'" ); - $storage = new WP_SQLite_Storage( $database_root, $database_path ); + $storage = WP_SQLite_Storage::with_explicit_path( $database_path ); $this->set_storage_property( $storage, 'database_lock_timeout', 10 ); try { @@ -723,7 +1338,7 @@ private function assert_legacy_database_is_migrated( $filename ) { $legacy_path = $database_root . '/' . $filename; $this->create_wal_sqlite_database_copy( $legacy_path ); - $database_path = $this->initialize_managed_storage( $database_root ); + $database_path = $this->initialize_secret_path( $database_root ); $stored_database_path = require $database_root . '/db-path.php'; $this->assertSame( $database_path, $stored_database_path ); @@ -750,8 +1365,8 @@ private function assert_creates_no_files( $callback ) { $this->assertSame( array( '.', '..' ), scandir( $working_directory ) ); } - private function initialize_managed_storage( $database_root ) { - $storage = new WP_SQLite_Storage( $database_root ); + private function initialize_secret_path( $database_root ) { + $storage = WP_SQLite_Storage::with_secret_path( $database_root ); return $storage->initialize(); } @@ -788,7 +1403,7 @@ private function make_read_only( $directory ) { private function open_storage_process( $database_root, $script ) { $prelude = sprintf( - 'require %s; $storage = new WP_SQLite_Storage(%s); ', + 'require %s; $storage = WP_SQLite_Storage::with_secret_path(%s); ', var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-storage.php', true ), var_export( $database_root, true ) ); @@ -796,6 +1411,29 @@ private function open_storage_process( $database_root, $script ) { return $this->open_process( $prelude . $script ); } + private function open_dropin_process( $settings, $script, $before_dropin = '' ) { + $settings += array( + 'ABSPATH' => ABSPATH, + 'WPINC' => WPINC, + 'WP_DEBUG' => false, + 'DB_ENGINE' => 'sqlite', + 'DB_NAME' => 'storage_test', + ); + $prelude = sprintf( + 'foreach (%s as $name => $value) { define($name, $value); } + require ABSPATH . WPINC . "/compat.php"; + require ABSPATH . WPINC . "/load.php"; + require ABSPATH . WPINC . "/plugin.php"; + require ABSPATH . WPINC . "/functions.php"; + require ABSPATH . WPINC . "/class-wpdb.php"; + require %s;', + var_export( $settings, true ), + var_export( WP_CONTENT_DIR . '/plugins/sqlite-database-integration/wp-includes/sqlite/db.php', true ) + ); + + return $this->open_process( $before_dropin . $prelude . $script ); + } + private function open_database_connection_process( $database_path ) { $script = sprintf( '$connection = new PDO(%s); $connection->query("SELECT value FROM storage_test")->fetchColumn(); fwrite(STDOUT, "ready\n"); fflush(STDOUT); usleep(250000);',