diff --git a/README.md b/README.md index 70e1e65..829b6ed 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ and the [CLI-first benchmark requirements](tests/bench/README.md#priorities-and- MDI selects its backend in `wp-content/db.php` before plugins load: - An explicit `MARKDOWN_DB_BACKEND` configuration always wins. -- Without one, an existing SQLite database (`FQDB`, when it names a file, or - `wp-content/database/.ht.sqlite`) keeps the SQLite backend so an upgrade does - not change an operating site's query engine. +- Without one, an existing SQLite database or managed `db-path.php` file keeps + the SQLite backend so an upgrade does not change an operating site's query + engine. Discovery honors `DB_PATH` before legacy settings and recognizes + legacy `.ht.sqlite` and `.ht.sqlite.php` files. It does not create or migrate + a database. - Otherwise, MDI selects **`mdi-native`**, where canonical Markdown and JSON files are the database. diff --git a/db.php b/db.php index a4441ed..287645f 100644 --- a/db.php +++ b/db.php @@ -80,10 +80,36 @@ function markdown_database_integration_configured_backend(): string { if ( defined( 'MARKDOWN_DB_BACKEND' ) ) { return (string) MARKDOWN_DB_BACKEND; } - if ( defined( 'FQDB' ) && is_file( FQDB ) ) { + if ( defined( 'DB_PATH' ) || defined( 'FQDB' ) ) { + $path = defined( 'DB_PATH' ) ? DB_PATH : FQDB; + return is_string( $path ) && is_file( $path ) ? 'sqlite' : 'mdi-native'; + } + + $directory = markdown_database_integration_sqlite_directory(); + if ( defined( 'DB_FILE' ) ) { + return is_file( $directory . DB_FILE ) ? 'sqlite' : 'mdi-native'; + } + + // Handle the randomized secret database path. + if ( is_file( $directory . 'db-path.php' ) ) { return 'sqlite'; } - return is_file( __DIR__ . '/database/.ht.sqlite' ) ? 'sqlite' : 'mdi-native'; + return is_file( $directory . '.ht.sqlite' ) || is_file( $directory . '.ht.sqlite.php' ) ? 'sqlite' : 'mdi-native'; + } +} + +if ( ! function_exists( 'markdown_database_integration_sqlite_directory' ) ) { + /** Resolve the SQLite storage directory before integration constants load. */ + function markdown_database_integration_sqlite_directory(): string { + if ( defined( 'FQDBDIR' ) ) { + $directory = FQDBDIR; + } elseif ( defined( 'DB_DIR' ) ) { + $directory = DB_DIR; + } else { + $directory = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : __DIR__; + $directory .= '/database'; + } + return rtrim( $directory, '/\\' ) . '/'; } } @@ -326,12 +352,13 @@ function markdown_database_integration_primary_index_path( string $content_dir, define( 'WP_SQLITE_AST_DRIVER', true ); } -// Primary mode uses markdown-index.sqlite as the active query engine. The -// SQLite Integration install shim opens FQDB directly during wp_install(). Only -// point FQDB at the markdown index when the markdown store already represents an +// Primary mode uses markdown-index.sqlite as the active query engine. +// Only point the SQLite installation shim at the index when the store represents an // installed site. Partial seed stores fall back to the existing SQLite database // so already-installed Playground sites do not reset to the install screen. -if ( defined( 'MARKDOWN_DB_MODE' ) && 'primary' === MARKDOWN_DB_MODE ) { +$markdown_db_primary = defined( 'MARKDOWN_DB_MODE' ) && 'primary' === MARKDOWN_DB_MODE; +$markdown_db_install_fallback = false; +if ( $markdown_db_primary ) { $markdown_db_content_dir = defined( 'MARKDOWN_DB_CONTENT_DIR' ) ? MARKDOWN_DB_CONTENT_DIR : markdown_db_default_content_dir(); @@ -353,6 +380,37 @@ function markdown_database_integration_primary_index_path( string $content_dir, if ( ! defined( 'FQDB' ) ) { define( 'FQDB', MARKDOWN_DB_INDEX_PATH ); } + } else { + $markdown_db_install_fallback = true; + if ( ! defined( 'MARKDOWN_DB_INSTALL_FALLBACK' ) ) { + define( 'MARKDOWN_DB_INSTALL_FALLBACK', true ); + } + } +} + +// Select configured paths before older integrations define FQDB by default. +$markdown_db_sqlite_storage_file = $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/class-wp-sqlite-storage.php'; +$markdown_db_sqlite_path = null; +if ( defined( 'DB_PATH' ) ) { + $markdown_db_sqlite_path = DB_PATH; +} elseif ( defined( 'FQDB' ) ) { + $markdown_db_sqlite_path = FQDB; +} elseif ( defined( 'DB_FILE' ) ) { + $markdown_db_sqlite_path = markdown_database_integration_sqlite_directory() . DB_FILE; +} elseif ( ! is_file( $markdown_db_sqlite_storage_file ) ) { + $markdown_db_sqlite_path = markdown_database_integration_sqlite_directory() . '.ht.sqlite'; + if ( ! is_file( $markdown_db_sqlite_path ) && is_file( $markdown_db_sqlite_path . '.php' ) ) { + $markdown_db_sqlite_path .= '.php'; + } +} + +// Older integrations read the legacy constants instead of DB_PATH. +if ( null !== $markdown_db_sqlite_path ) { + if ( ! defined( 'FQDB' ) ) { + define( 'FQDB', $markdown_db_sqlite_path ); + } + if ( ! defined( 'FQDBDIR' ) && ':memory:' !== $markdown_db_sqlite_path ) { + define( 'FQDBDIR', rtrim( dirname( $markdown_db_sqlite_path ), '/\\' ) . '/' ); } } @@ -368,6 +426,11 @@ function markdown_database_integration_primary_index_path( string $content_dir, // Load the SQLite v2 driver stack (parser, lexer, connection, driver). require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/database/load.php'; +// Load SQLite storage support when available. +if ( is_file( $markdown_db_sqlite_storage_file ) ) { + require_once $markdown_db_sqlite_storage_file; +} + // SQLite Integration renamed the canonical PDO driver after its latest release. if ( ! class_exists( 'WP_MySQL_On_SQLite', false ) ) { if ( class_exists( 'WP_PDO_MySQL_On_SQLite', false ) ) { @@ -386,24 +449,6 @@ class_alias( 'WP_PDO_MySQL_On_SQLite', 'WP_MySQL_On_SQLite' ); require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/class-wp-sqlite-db.php'; require_once $sqlite_plugin_implementation_folder_path . '/wp-includes/sqlite/install-functions.php'; -if ( defined( 'MARKDOWN_DB_MODE' ) && 'primary' === MARKDOWN_DB_MODE ) { - $markdown_db_content_dir = defined( 'MARKDOWN_DB_CONTENT_DIR' ) - ? MARKDOWN_DB_CONTENT_DIR - : markdown_db_default_content_dir(); - $markdown_db_state_dir = defined( 'MARKDOWN_DB_STATE_DIR' ) - ? MARKDOWN_DB_STATE_DIR - : $markdown_db_content_dir; - - if ( ! markdown_database_integration_store_has_siteurl( $markdown_db_state_dir ) ) { - if ( ! defined( 'MARKDOWN_DB_INSTALL_FALLBACK' ) ) { - define( 'MARKDOWN_DB_INSTALL_FALLBACK', true ); - } - $db_name = defined( 'DB_NAME' ) && '' !== DB_NAME ? DB_NAME : 'database_name_here'; - $GLOBALS['wpdb'] = new WP_SQLite_DB( $db_name ); - return; - } -} - // Load our markdown classes. $markdown_plugin_dir = null; @@ -420,32 +465,46 @@ class_alias( 'WP_PDO_MySQL_On_SQLite', 'WP_MySQL_On_SQLite' ); } } -// If the markdown plugin isn't installed yet, fall back to standard SQLite. -if ( ! $markdown_plugin_dir ) { - // Fallback: standard SQLite behavior. - if ( defined( 'DB_NAME' ) && '' !== DB_NAME ) { - $db_name = DB_NAME; - } else { - $db_name = 'database_name_here'; +$markdown_db_sqlite_fallback = $markdown_db_install_fallback || ! $markdown_plugin_dir; + +if ( ! $markdown_db_sqlite_fallback ) { + // MDI is storage-only; content-format dependencies belong to the application. + $composer_autoload = $markdown_plugin_dir . '/vendor/autoload.php'; + if ( file_exists( $composer_autoload ) ) { + require_once $composer_autoload; } - $GLOBALS['wpdb'] = new WP_SQLite_DB( $db_name ); - return; + + require_once $markdown_plugin_dir . '/inc/class-wp-markdown-backend-capabilities.php'; + $markdown_db_backend = WP_Markdown_Backend_Resolver::configure_from_globals(); + WP_Markdown_Backend_Resolver::require_runtime_capabilities( + $markdown_db_backend, + defined( 'MARKDOWN_DB_MODE' ) ? (string) MARKDOWN_DB_MODE : 'mirror' + ); +} + +// Primary mode creates its index during reconstruction, after storage is loaded. +if ( class_exists( 'WP_SQLite_Storage' ) && ( ! $markdown_db_primary || $markdown_db_sqlite_fallback ) ) { + $storage = null !== $markdown_db_sqlite_path + ? WP_SQLite_Storage::with_explicit_path( $markdown_db_sqlite_path ) + : WP_SQLite_Storage::with_secret_path( FQDBDIR ); + $markdown_db_sqlite_path = $storage->initialize(); + if ( ! defined( 'FQDB' ) ) { + define( 'FQDB', $markdown_db_sqlite_path ); + } +} +if ( ! defined( 'DB_PATH' ) ) { + define( 'DB_PATH', $markdown_db_sqlite_path ); } -// Load composer autoloader if present. MDI is storage-only; content-format -// dependencies belong to the application layer above this drop-in. -$composer_autoload = $markdown_plugin_dir . '/vendor/autoload.php'; -if ( file_exists( $composer_autoload ) ) { - require_once $composer_autoload; +$db_name = defined( 'DB_NAME' ) && '' !== DB_NAME ? DB_NAME : 'database_name_here'; + +// Use standard SQLite for installation or when the markdown plugin is missing. +if ( $markdown_db_sqlite_fallback ) { + $GLOBALS['wpdb'] = new WP_SQLite_DB( $db_name ); + return; } // Load markdown integration classes. -require_once $markdown_plugin_dir . '/inc/class-wp-markdown-backend-capabilities.php'; -$markdown_db_backend = WP_Markdown_Backend_Resolver::configure_from_globals(); -WP_Markdown_Backend_Resolver::require_runtime_capabilities( - $markdown_db_backend, - defined( 'MARKDOWN_DB_MODE' ) ? (string) MARKDOWN_DB_MODE : 'mirror' -); require_once $markdown_plugin_dir . '/inc/class-wp-markdown-frontmatter-profiles.php'; require_once $markdown_plugin_dir . '/inc/class-wp-markdown-content-layout-profiles.php'; if ( defined( 'MARKDOWN_DB_CONTENT_LAYOUT_PROFILE_BOOTSTRAP' ) && is_file( MARKDOWN_DB_CONTENT_LAYOUT_PROFILE_BOOTSTRAP ) ) { @@ -466,12 +525,6 @@ class_alias( 'WP_PDO_MySQL_On_SQLite', 'WP_MySQL_On_SQLite' ); } // Create the database connection — our WP_Markdown_DB extends WP_SQLite_DB. -if ( defined( 'DB_NAME' ) && '' !== DB_NAME ) { - $db_name = DB_NAME; -} else { - $db_name = 'database_name_here'; -} - $GLOBALS['wpdb'] = new WP_Markdown_DB( $db_name ); markdown_database_integration_enable_native_shadow( $GLOBALS['wpdb'], $markdown_plugin_dir ); diff --git a/inc/class-wp-markdown-db.php b/inc/class-wp-markdown-db.php index 039a6ff..b94a252 100644 --- a/inc/class-wp-markdown-db.php +++ b/inc/class-wp-markdown-db.php @@ -128,8 +128,8 @@ public function db_connect( $allow_bail = true ) { $pdo = null; } else { // Phase 1 / mirror: use on-disk SQLite file. - $db_path = FQDB; - $this->ensure_database_directory_exists(); + $db_path = defined( 'DB_PATH' ) ? DB_PATH : FQDB; + $this->ensure_database_directory_exists( $db_path ); } // Create the markdown storage engine. @@ -674,8 +674,11 @@ private function ensure_directory_exists( string $dir ): void { /** * Ensure the SQLite database directory exists with protections. */ - private function ensure_database_directory_exists(): void { - $dir = dirname( FQDB ); + private function ensure_database_directory_exists( string $db_path ): void { + if ( ':memory:' === $db_path ) { + return; + } + $dir = dirname( $db_path ); $this->ensure_directory_exists( $dir ); // .htaccess protection. diff --git a/inc/sqlite/class-wp-markdown-sqlite-runtime-adapter.php b/inc/sqlite/class-wp-markdown-sqlite-runtime-adapter.php index 53134b2..a43189e 100644 --- a/inc/sqlite/class-wp-markdown-sqlite-runtime-adapter.php +++ b/inc/sqlite/class-wp-markdown-sqlite-runtime-adapter.php @@ -142,13 +142,14 @@ public function __construct( WP_Markdown_Storage $storage, ?WP_Markdown_Backend_Capabilities $backend_capabilities = null ) { + $pdo_option = defined( 'MARKDOWN_DB_SQLITE_LEGACY_RESULT_API' ) && MARKDOWN_DB_SQLITE_LEGACY_RESULT_API ? 'pdo' : 'sqlite_pdo'; parent::__construct( sprintf( 'mysql-on-sqlite:dbname=%s', $database ), null, null, array( - 'pdo' => $connection->get_pdo(), - 'journal_mode' => $connection->get_pdo()->query( 'PRAGMA journal_mode' )->fetchColumn(), + $pdo_option => $connection->get_pdo(), + 'sqlite_journal_mode' => $connection->get_pdo()->query( 'PRAGMA journal_mode' )->fetchColumn(), ) ); $connection->get_pdo()->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); diff --git a/tests/probe-sqlite-fallback.php b/tests/probe-sqlite-fallback.php new file mode 100644 index 0000000..8e01da4 --- /dev/null +++ b/tests/probe-sqlite-fallback.php @@ -0,0 +1,201 @@ + array( 'FQDB', 'custom.sqlite', false ), + 'default-fqdb' => array( 'FQDB', '.ht.sqlite', false ), + 'existing-fqdb' => array( 'FQDB', 'custom.sqlite', true ), + 'memory-fqdb' => array( 'FQDB', ':memory:', false ), + 'new-db-path' => array( 'DB_PATH', 'custom.sqlite', false ), + 'default-db-path' => array( 'DB_PATH', '.ht.sqlite', false ), + 'existing-db-path' => array( 'DB_PATH', 'custom.sqlite', true ), + 'memory-db-path' => array( 'DB_PATH', ':memory:', false ), + 'conflicting-paths' => array( 'DB_PATH', 'custom.sqlite', false ), + 'new-db-file' => array( 'DB_FILE', 'custom.sqlite', false ), + 'default-db-file' => array( 'DB_FILE', '.ht.sqlite', false ), + 'existing-db-file' => array( 'DB_FILE', 'custom.sqlite', true ), +); + +if ( 3 === $argc ) { + $failed = 0; + foreach ( array( 'primary', 'missing-plugin', 'mirror' ) as $fallback ) { + $scenarios = 'mirror' === $fallback ? array_merge( array_keys( $configured_paths ), array( 'unknown-backend' ) ) : array( 'missing-name', 'empty-name', 'engine-override' ); + $layouts = array( 'mirror' === $fallback ? 'legacy-php' : 'legacy' ); + if ( $managed ) { + $layouts[] = 'randomized'; + } + foreach ( $scenarios as $scenario ) { + foreach ( $layouts as $layout ) { + $command = array( PHP_BINARY, __FILE__, $wordpress, $sqlite, $fallback, $scenario, $layout ); + passthru( implode( ' ', array_map( 'escapeshellarg', $command ) ), $status ); + $failed += 0 === $status ? 0 : 1; + } + } + } + exit( $failed ? 1 : 0 ); +} + +[ , , , $fallback, $scenario, $layout ] = $argv; +$mirror = 'mirror' === $fallback; +$root = sys_get_temp_dir() . '/mdi-sqlite-fallback-' . getmypid(); +$content = $root . '/wp-content'; +mkdir( $content . '/plugins', 0755, true ); +register_shutdown_function( static function () use ( $root ): void { + $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); + foreach ( $files as $file ) { + $file->isDir() && ! $file->isLink() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $root ); +} ); +if ( 'missing-plugin' !== $fallback ) { + symlink( dirname( __DIR__ ), $content . '/plugins/markdown-database-integration' ); +} +symlink( $sqlite, $content . '/plugins/sqlite-database-integration' ); +copy( dirname( __DIR__ ) . '/db.php', $content . '/db.php' ); +chdir( $root ); + +define( 'ABSPATH', $wordpress . '/' ); +define( 'WPINC', 'wp-includes' ); +define( 'WP_CONTENT_DIR', $content ); +define( 'WP_DEBUG', false ); +define( 'MARKDOWN_DB_VERSION', 'test' ); +define( 'MARKDOWN_DB_BACKEND', 'unknown-backend' === $scenario ? 'unknown' : 'sqlite' ); +define( 'MARKDOWN_DB_MODE', 'primary' === $fallback ? 'primary' : 'mirror' ); +if ( 'missing-name' !== $scenario ) { + define( 'DB_NAME', 'empty-name' === $scenario ? '' : 'wordpress' ); +} +if ( 'engine-override' === $scenario ) { + define( 'DB_ENGINE', 'mysql' ); +} +$GLOBALS['table_prefix'] = 'wp_'; + +$directory = $content . '/database'; +if ( 'randomized' === $layout ) { + require_once $sqlite . '/wp-includes/sqlite/class-wp-sqlite-storage.php'; + $expected_path = WP_SQLite_Storage::with_secret_path( $directory )->initialize(); +} else { + mkdir( $directory, 0755 ); + $expected_path = $directory . ( 'legacy-php' === $layout ? '/.ht.sqlite.php' : '/.ht.sqlite' ); +} +$seed = new PDO( 'sqlite:' . $expected_path ); +$seed->exec( 'CREATE TABLE fallback_probe (value TEXT)' ); +$seed->exec( "INSERT INTO fallback_probe VALUES ('existing database')" ); +$seed = null; + +$expected_value = 'existing database'; +if ( $mirror ) { + $neighbor_path = $expected_path; + $neighbor_hash = hash_file( 'sha256', $neighbor_path ); + $directory_before = scandir( $directory ); + $metadata_hash = $managed && 'randomized' === $layout ? hash_file( 'sha256', $directory . '/db-path.php' ) : null; +} +if ( isset( $configured_paths[ $scenario ] ) ) { + [ $constant, $filename, $existing ] = $configured_paths[ $scenario ]; + $expected_path = ':memory:' === $filename ? $filename : $directory . '/' . $filename; + define( $constant, 'DB_FILE' === $constant ? $filename : $expected_path ); + if ( 'conflicting-paths' === $scenario ) { + define( 'FQDB', $neighbor_path ); + } + $expected_value = null; + if ( $existing ) { + $seed = new PDO( 'sqlite:' . $expected_path ); + $seed->exec( 'CREATE TABLE fallback_probe (value TEXT)' ); + $seed->exec( "INSERT INTO fallback_probe VALUES ('configured database')" ); + $seed = null; + $expected_value = 'configured database'; + } +} + +foreach ( array( 'compat.php', 'plugin.php', 'load.php', 'formatting.php', 'class-wpdb.php' ) as $file ) { + require_once ABSPATH . WPINC . '/' . $file; +} +set_error_handler( static function ( int $severity, string $message, string $file, int $line ) use ( $scenario ): bool { + if ( ! ( error_reporting() & $severity ) ) { + return false; + } + if ( 'conflicting-paths' === $scenario && E_USER_WARNING === $severity && 'FQDB conflicts with DB_PATH.' === $message ) { + return true; + } + throw new ErrorException( $message, 0, $severity, $file, $line ); +} ); +try { + require $content . '/db.php'; +} catch ( WP_Markdown_Unknown_Backend $error ) { + if ( 'unknown-backend' !== $scenario ) { + throw $error; + } + if ( $directory_before !== scandir( $directory ) || $neighbor_hash !== hash_file( 'sha256', $neighbor_path ) ) { + throw new RuntimeException( 'An unsupported backend changed SQLite storage.' ); + } + echo "PASS: {$layout} storage is unchanged when backend selection fails.\n"; + exit( 0 ); +} +restore_error_handler(); +if ( 'unknown-backend' === $scenario ) { + throw new RuntimeException( 'An unsupported backend did not fail.' ); +} + +$wpdb = $GLOBALS['wpdb'] ?? null; +$expected_class = $mirror ? 'WP_Markdown_DB' : 'WP_SQLite_DB'; +if ( ! $wpdb instanceof WP_SQLite_DB || $expected_class !== get_class( $wpdb ) || ! $wpdb->ready ) { + throw new RuntimeException( 'The selected SQLite database did not connect.' ); +} +if ( 'primary' === $fallback && ! MARKDOWN_DB_INSTALL_FALLBACK ) { + throw new RuntimeException( 'Primary mode did not select the installation fallback.' ); +} +$expected_name = $mirror || 'engine-override' === $scenario ? 'wordpress' : 'database_name_here'; +if ( $expected_name !== $wpdb->get_var( 'SELECT DATABASE()' ) ) { + throw new RuntimeException( 'The fallback used the wrong database name.' ); +} +if ( $managed && ! $mirror ) { + $expected_path = include $directory . '/db-path.php'; +} +$pdo = $GLOBALS['@pdo']; +$active_path = $pdo->query( 'PRAGMA database_list' )->fetch( PDO::FETCH_ASSOC )['file']; +if ( ':memory:' === $expected_path ) { + if ( '' !== $active_path || is_file( $root . '/.htaccess' ) || is_file( $root . '/index.php' ) ) { + throw new RuntimeException( 'In-memory storage created filesystem artifacts.' ); + } +} else { + if ( realpath( $expected_path ) !== realpath( $active_path ) ) { + throw new RuntimeException( 'Opened the wrong database: ' . $active_path ); + } + if ( ! is_file( dirname( $active_path ) . '/.htaccess' ) || ! is_file( dirname( $active_path ) . '/index.php' ) ) { + throw new RuntimeException( 'The active database directory lacks protections.' ); + } +} +if ( null === $expected_value ) { + if ( 0 !== (int) $pdo->query( "SELECT COUNT(*) FROM sqlite_master WHERE name = 'fallback_probe'" )->fetchColumn() ) { + throw new RuntimeException( 'A new configured database inherited neighboring data.' ); + } +} elseif ( $expected_value !== $pdo->query( 'SELECT value FROM fallback_probe' )->fetchColumn() ) { + throw new RuntimeException( 'The selected database content was not preserved.' ); +} +if ( $mirror ) { + if ( DB_PATH !== $expected_path || $neighbor_hash !== hash_file( 'sha256', $neighbor_path ) ) { + throw new RuntimeException( 'The configured path changed or neighboring storage was modified.' ); + } + if ( null !== $metadata_hash && $metadata_hash !== hash_file( 'sha256', $directory . '/db-path.php' ) ) { + throw new RuntimeException( 'An explicit path changed managed storage metadata.' ); + } +} + +echo "PASS: {$fallback}/{$scenario}/{$layout} uses the selected SQLite storage.\n"; diff --git a/tests/probe-sqlite-path-discovery.php b/tests/probe-sqlite-path-discovery.php new file mode 100644 index 0000000..ab300ed --- /dev/null +++ b/tests/probe-sqlite-path-discovery.php @@ -0,0 +1,191 @@ +isDir() && ! $file->isLink() ? rmdir( $file->getPathname() ) : unlink( $file->getPathname() ); + } + rmdir( $root ); +} ); +symlink( dirname( __DIR__ ), $content . '/plugins/markdown-database-integration' ); +symlink( $sqlite, $content . '/plugins/sqlite-database-integration' ); +copy( dirname( __DIR__ ) . '/db.php', $content . '/db.php' ); +chdir( $root ); + +define( 'ABSPATH', $wordpress . '/' ); +define( 'WPINC', 'wp-includes' ); +define( 'WP_CONTENT_DIR', $content ); +define( 'WP_DEBUG', false ); +define( 'DB_NAME', 'wordpress' ); +define( 'MARKDOWN_DB_VERSION', 'test' ); +define( 'MARKDOWN_DB_MODE', $mode ); +$GLOBALS['table_prefix'] = 'wp_'; + +$database_root = $content . '/database'; +$expected_path = $database_root . '/.ht.sqlite'; +switch ( $scenario ) { + case 'fqdb': + $expected_path = $root . '/configured/legacy.sqlite'; + define( 'FQDB', $expected_path ); + break; + case 'db-file': + $database_root = $root . '/configured'; + $expected_path = $database_root . '/custom.sqlite'; + define( 'DB_DIR', $database_root ); + define( 'DB_FILE', 'custom.sqlite' ); + break; + case 'db-path': + case 'conflicting-paths': + case 'both-paths': + case 'new-db-path': + $expected_path = $root . '/configured/active.sqlite'; + define( 'DB_PATH', $expected_path ); + if ( in_array( $scenario, array( 'conflicting-paths', 'both-paths' ), true ) ) { + define( 'FQDB', $root . '/unused/wrong.sqlite' ); + define( 'FQDBDIR', $root . '/unused/' ); + if ( 'both-paths' === $scenario ) { + mkdir( dirname( FQDB ), 0755 ); + $wrong = new PDO( 'sqlite:' . FQDB ); + $wrong->exec( 'CREATE TABLE wrong_database (value TEXT)' ); + $wrong = null; + $wrong_hash = hash_file( 'sha256', FQDB ); + } + } + break; + case 'memory': + $expected_path = ':memory:'; + define( 'DB_PATH', $expected_path ); + break; + case 'legacy-php': + $expected_path .= '.php'; + break; + case 'custom-managed': + $database_root = $root . '/configured'; + define( 'DB_DIR', $database_root ); + // Fall through to create real managed storage. + case 'randomized': + case 'missing-recorded': + require_once $sqlite . '/wp-includes/sqlite/class-wp-sqlite-storage.php'; + $expected_path = WP_SQLite_Storage::with_secret_path( $database_root )->initialize(); + if ( 'missing-recorded' === $scenario ) { + unlink( $expected_path ); + } + break; +} + +$existing = ! in_array( $scenario, array( 'new-db-path', 'memory', 'fresh-managed', 'missing-recorded' ), true ); +if ( $existing ) { + if ( ! is_dir( dirname( $expected_path ) ) ) { + mkdir( dirname( $expected_path ), 0755, true ); + } + $seed = new PDO( 'sqlite:' . $expected_path ); + $seed->exec( 'CREATE TABLE path_probe (value TEXT)' ); + $seed->exec( "INSERT INTO path_probe VALUES ('configured database')" ); + $seed = null; +} elseif ( 'missing-recorded' !== $scenario ) { + define( 'MARKDOWN_DB_BACKEND', 'sqlite' ); +} + +require_once ABSPATH . WPINC . '/compat.php'; +require_once ABSPATH . WPINC . '/plugin.php'; +require_once ABSPATH . WPINC . '/load.php'; +require_once ABSPATH . WPINC . '/formatting.php'; +require_once ABSPATH . WPINC . '/class-wpdb.php'; + +set_error_handler( static function ( int $severity, string $message, string $file, int $line ): bool { + if ( ! ( error_reporting() & $severity ) ) { + return false; + } + if ( E_USER_WARNING === $severity && in_array( $message, array( 'FQDB conflicts with DB_PATH.', 'FQDBDIR conflicts with DB_PATH.' ), true ) ) { + return true; + } + throw new ErrorException( $message, 0, $severity, $file, $line ); +} ); +require $content . '/db.php'; +restore_error_handler(); + +if ( 'sqlite' !== MARKDOWN_DB_BACKEND || ! $GLOBALS['wpdb'] instanceof WP_SQLite_DB || ! $GLOBALS['wpdb']->ready ) { + throw new RuntimeException( 'The SQLite database did not connect: ' . $GLOBALS['wpdb']->last_error ); +} +if ( 'primary' === $mode ) { + if ( ! defined( 'MARKDOWN_DB_INSTALL_FALLBACK' ) || $GLOBALS['wpdb'] instanceof WP_Markdown_DB ) { + throw new RuntimeException( 'Primary mode did not use the SQLite installation fallback.' ); + } +} elseif ( ! $GLOBALS['wpdb'] instanceof WP_Markdown_DB ) { + throw new RuntimeException( 'Mirror mode did not use the Markdown database.' ); +} +$pdo = $GLOBALS['@pdo']; +$active_path = $pdo->query( 'PRAGMA database_list' )->fetch( PDO::FETCH_ASSOC )['file']; +if ( 'memory' === $scenario ) { + if ( '' !== $active_path || is_dir( $database_root ) || is_file( getcwd() . '/.htaccess' ) ) { + throw new RuntimeException( 'In-memory storage created filesystem artifacts.' ); + } +} else { + if ( $managed && in_array( $scenario, array( 'legacy', 'legacy-php', 'fresh-managed' ), true ) ) { + $expected_path = include $database_root . '/db-path.php'; + } elseif ( 'primary' === $mode && 'legacy-php' === $scenario ) { + $expected_path = FQDB; + } + if ( realpath( $expected_path ) !== realpath( $active_path ) ) { + throw new RuntimeException( 'Opened the wrong database: ' . json_encode( array( 'expected' => $expected_path, 'databases' => $pdo->query( 'PRAGMA database_list' )->fetchAll( PDO::FETCH_ASSOC ) ) ) ); + } + if ( ! is_file( dirname( $active_path ) . '/.htaccess' ) || ! is_file( dirname( $active_path ) . '/index.php' ) ) { + throw new RuntimeException( 'The active database directory lacks protections.' ); + } +} +if ( $existing && 'configured database' !== $pdo->query( 'SELECT value FROM path_probe' )->fetchColumn() ) { + throw new RuntimeException( 'The existing database content was not preserved.' ); +} +if ( 'both-paths' === $scenario ) { + if ( $wrong_hash !== hash_file( 'sha256', FQDB ) || is_file( dirname( FQDB ) . '/.htaccess' ) || is_file( dirname( FQDB ) . '/index.php' ) ) { + throw new RuntimeException( 'Mirror changed the conflicting FQDB storage.' ); + } +} elseif ( is_dir( $root . '/unused' ) ) { + throw new RuntimeException( 'Mirror created the conflicting FQDB directory.' ); +} + +echo "PASS: {$mode}/{$scenario} connects to the selected database and protects its directory.\n"; diff --git a/tests/smoke-drop-index-schema-persistence.php b/tests/smoke-drop-index-schema-persistence.php index a152df4..8ee8954 100644 --- a/tests/smoke-drop-index-schema-persistence.php +++ b/tests/smoke-drop-index-schema-persistence.php @@ -34,7 +34,7 @@ class WP_MySQL_On_SQLite { public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->connection = new WP_SQLite_Connection( $options['pdo'] ); + $this->connection = new WP_SQLite_Connection( $options['sqlite_pdo'] ); } private WP_SQLite_Connection $connection; @@ -139,7 +139,7 @@ function mdi_drop_index_rm( string $path ): void { $cold_pdo = new PDO( 'sqlite::memory:' ); $cold_pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); -$cold_driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'pdo' => $cold_pdo ) ); +$cold_driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'sqlite_pdo' => $cold_pdo ) ); $loader = new WP_Markdown_Loader( $root, $cold_driver, new WP_Markdown_Storage( $root ), 'wp_' ); $load_plugin_tables = new ReflectionMethod( $loader, 'load_plugin_tables' ); $load_plugin_tables->invoke( $loader ); diff --git a/tests/smoke-dropin-backend-selection.php b/tests/smoke-dropin-backend-selection.php index 599f96b..8182922 100644 --- a/tests/smoke-dropin-backend-selection.php +++ b/tests/smoke-dropin-backend-selection.php @@ -105,6 +105,80 @@ define( 'DB_PASSWORD', 'password' ); define( 'DB_HOST', 'host' ); define( 'MARKDOWN_DB_VERSION', 'test' ); + if ( in_array( $scenario, array( 'primary-cold-index', 'primary-warm-index' ), true ) ) { + define( 'MARKDOWN_DB_BACKEND', 'sqlite' ); + define( 'MARKDOWN_DB_MODE', 'primary' ); + mkdir( $content . '/markdown/_options', 0755 ); + file_put_contents( $content . '/markdown/_options/siteurl.json', '"https://example.test"' ); + if ( 'primary-warm-index' === $scenario ) { + file_put_contents( $content . '/markdown-index.sqlite', 'existing primary index' ); + } + file_put_contents( $sqlite . '/wp-includes/sqlite/class-wp-sqlite-storage.php', ' 'FQDB', + 'default-db-file' => 'DB_FILE', + default => 'DB_PATH', + }; + define( $constant, 'DB_FILE' === $constant ? 'custom.sqlite' : $database_dir . '/custom.sqlite' ); + file_put_contents( $database_dir . '/custom.sqlite', 'configured database' ); + $expected_backend = 'sqlite'; + if ( 'default-conflicting-paths' === $scenario ) { + define( 'FQDB', $database_dir . '/missing.sqlite' ); + } + } + if ( in_array( $scenario, array( 'default-missing-db-path', 'default-missing-fqdb', 'default-missing-db-file' ), true ) ) { + $constant = match ( $scenario ) { + 'default-missing-fqdb' => 'FQDB', + 'default-missing-db-file' => 'DB_FILE', + default => 'DB_PATH', + }; + define( $constant, 'DB_FILE' === $constant ? 'missing.sqlite' : $database_dir . '/missing.sqlite' ); + if ( 'DB_PATH' === $constant ) { + define( 'FQDB', $database_dir . '/.ht.sqlite' ); + } + $expected_backend = 'mdi-native'; + } + $snapshot = static function () use ( $database_dir ): array { + $files = array( $database_dir => is_dir( $database_dir ) ); + if ( is_dir( $database_dir ) ) { + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $database_dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::SELF_FIRST ) as $file ) { + $files[ $file->getPathname() ] = $file->isDir() ? 'directory' : hash_file( 'sha256', $file->getPathname() ); + } + } + return $files; + }; + $storage_before = $snapshot(); + if ( 'mysql-content' === $scenario ) { + define( 'MARKDOWN_DB_BACKEND', 'mysql-content' ); + } if ( 'unknown' === $scenario ) { define( 'MARKDOWN_DB_BACKEND', 'unknown' ); } elseif ( 'incomplete' === $scenario ) { @@ -112,18 +186,15 @@ $GLOBALS['markdown_db_backend_declarations'] = array( 'incomplete' => array() ); } elseif ( 'sqlite' === $scenario ) { define( 'MARKDOWN_DB_BACKEND', 'sqlite' ); - } elseif ( in_array( $scenario, array( 'mdi-native', 'default-native', 'default-legacy-sqlite' ), true ) ) { + } elseif ( in_array( $scenario, array( 'mdi-native', 'mysql-content' ), true ) || str_starts_with( $scenario, 'default-' ) ) { // The default scenarios leave MARKDOWN_DB_BACKEND undefined on purpose. if ( 'mdi-native' === $scenario ) { define( 'MARKDOWN_DB_BACKEND', 'mdi-native' ); } - if ( 'default-legacy-sqlite' === $scenario ) { - mkdir( $content . '/database', 0755, true ); - file_put_contents( $content . '/database/.ht.sqlite', 'SQLite format 3' ); - } define( 'MARKDOWN_DB_STATE_DIR', $content . '/markdown' ); define( 'MARKDOWN_DB_CONTENT_DIR', $content . '/markdown' ); class wpdb { + public bool $is_mysql = false; public string $prefix = ''; public string $base_prefix = ''; public array $last_result = array(); @@ -188,6 +259,16 @@ class wpdb { public string $prefix = 'wp_'; public string $base_prefix = 'wp_'; } }; $bootstrap(); + if ( in_array( $scenario, array( 'primary-cold-index', 'primary-warm-index' ), true ) ) { + if ( ! $GLOBALS['wpdb'] instanceof WP_Markdown_DB || DB_PATH !== $content . '/markdown-index.sqlite' || defined( 'MARKDOWN_DB_INSTALL_FALLBACK' ) ) { + throw new RuntimeException( 'An installed primary site did not use its index.' ); + } + if ( 'primary-cold-index' === $scenario ? file_exists( DB_PATH ) : 'existing primary index' !== file_get_contents( DB_PATH ) ) { + throw new RuntimeException( 'The drop-in created or changed the primary index.' ); + } + echo "PASS: {$scenario} leaves index initialization to primary mode.\n"; + exit( 0 ); + } if ( in_array( $scenario, array( 'mysql-full', 'mysql-full-shadow', 'mysql-full-no-sink', 'mysql-full-prior-outbox' ), true ) && $GLOBALS['wpdb'] instanceof WP_Markdown_MySQL_WPDB && ( $GLOBALS['markdown_db_mysql_outbox'] ?? null ) instanceof WP_Markdown_MySQL_Outbox ) { if ( 'mysql-full-prior-outbox' === $scenario && ! $GLOBALS['wpdb']->markdown_db_mysql_connection()->upgraded ) { throw new RuntimeException( 'Prior outbox schema was not upgraded.' ); } if ( 'mysql-full-shadow' === $scenario && ! ( $GLOBALS['markdown_db_native_shadow_verifier'] ?? null ) instanceof WP_Markdown_Native_Shadow_Verifier ) { throw new RuntimeException( 'Native shadow verifier was not attached.' ); } @@ -198,18 +279,18 @@ class wpdb { public string $prefix = 'wp_'; public string $base_prefix = 'wp_'; echo "PASS: mdi-native installs the filesystem-backed wpdb boundary.\n"; exit( 0 ); } - if ( 'default-native' === $scenario ) { - if ( ! $GLOBALS['wpdb'] instanceof WP_Markdown_Native_WPDB || 'mdi-native' !== MARKDOWN_DB_BACKEND ) { - throw new RuntimeException( 'An unconfigured install did not serve canonical files natively.' ); - } - echo "PASS: an unconfigured install serves canonical files without SQLite.\n"; + if ( $storage_before !== $snapshot() ) { + throw new RuntimeException( 'Backend detection changed SQLite storage.' ); + } + if ( 'mysql-content' === $scenario && $GLOBALS['wpdb'] instanceof wpdb && MARKDOWN_DB_RETAINED_DROPIN ) { + echo "PASS: explicit mysql-content overrides existing SQLite storage.\n"; exit( 0 ); } - if ( 'default-legacy-sqlite' === $scenario ) { - if ( $GLOBALS['wpdb'] instanceof WP_Markdown_Native_WPDB || 'sqlite' !== MARKDOWN_DB_BACKEND ) { - throw new RuntimeException( 'An existing SQLite database did not keep its query engine.' ); + if ( str_starts_with( $scenario, 'default-' ) ) { + if ( $expected_backend !== MARKDOWN_DB_BACKEND || ( 'mdi-native' === $expected_backend ) !== ( $GLOBALS['wpdb'] instanceof WP_Markdown_Native_WPDB ) ) { + throw new RuntimeException( 'Unexpected backend for ' . $scenario . ': ' . MARKDOWN_DB_BACKEND ); } - echo "PASS: an existing SQLite database keeps its query engine.\n"; + echo "PASS: {$scenario} selects {$expected_backend} without changing storage.\n"; exit( 0 ); } if ( in_array( $scenario, array( 'mysql-full-incompatible', 'mysql-full-outbox-incompatible' ), true ) && $GLOBALS['wpdb'] instanceof wpdb && str_starts_with( (string) ( $GLOBALS['markdown_db_mysql_full_diagnostic']['code'] ?? '' ), 'markdown_db_mysql_full_' ) ) { @@ -236,7 +317,7 @@ class wpdb { public string $prefix = 'wp_'; public string $base_prefix = 'wp_'; } $failed = 0; -foreach ( array( 'sqlite', 'default-native', 'default-legacy-sqlite', 'mdi-native', 'mysql-full', 'mysql-full-shadow', 'mysql-full-no-sink', 'mysql-full-prior-outbox', 'mysql-full-incompatible', 'mysql-full-outbox-incompatible', 'unknown', 'incomplete' ) as $scenario ) { +foreach ( array( 'sqlite', 'primary-cold-index', 'primary-warm-index', 'default-native', 'default-legacy-sqlite', 'default-legacy-php', 'default-db-dir', 'default-fqdbdir', 'default-randomized', 'default-missing-recorded', 'default-interrupted-migration', 'default-unread-metadata', 'default-db-path', 'default-fqdb', 'default-db-file', 'default-conflicting-paths', 'default-missing-db-path', 'default-missing-fqdb', 'default-missing-db-file', 'mdi-native', 'mysql-content', 'mysql-full', 'mysql-full-shadow', 'mysql-full-no-sink', 'mysql-full-prior-outbox', 'mysql-full-incompatible', 'mysql-full-outbox-incompatible', 'unknown', 'incomplete' ) as $scenario ) { passthru( escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __FILE__ ) . ' ' . escapeshellarg( $scenario ), $status ); if ( 0 !== $status ) { ++$failed; diff --git a/tests/smoke-ephemeral-option-policy.php b/tests/smoke-ephemeral-option-policy.php index 8ac9990..3be44bd 100644 --- a/tests/smoke-ephemeral-option-policy.php +++ b/tests/smoke-ephemeral-option-policy.php @@ -42,10 +42,10 @@ class WP_MySQL_On_SQLite { private WP_SQLite_Connection $connection; private PDO $pdo; - /** @param array{pdo?:PDO,rows?:object[]} $options */ + /** @param array{sqlite_pdo?:PDO,rows?:object[]} $options */ public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->pdo = $options['pdo'] ?? new PDO( 'sqlite::memory:' ); + $this->pdo = $options['sqlite_pdo'] ?? new PDO( 'sqlite::memory:' ); $this->connection = new WP_SQLite_Connection( $this->pdo ); $this->pdo->exec( 'CREATE TABLE IF NOT EXISTS mdi_rows (option_id INTEGER, option_name TEXT, option_value TEXT, autoload TEXT)' ); foreach ( $options['rows'] ?? array() as $row ) { @@ -141,7 +141,7 @@ static function ( array $names ): array { $pdo = new PDO( 'sqlite::memory:' ); $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); $pdo->exec( 'CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_name TEXT UNIQUE, option_value TEXT, autoload TEXT)' ); -$loader = new WP_Markdown_Loader( $root, new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'pdo' => $pdo ) ), new WP_Markdown_Storage( $root ), 'wp_', $root ); +$loader = new WP_Markdown_Loader( $root, new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'sqlite_pdo' => $pdo ) ), new WP_Markdown_Storage( $root ), 'wp_', $root ); $load_options = new ReflectionMethod( WP_Markdown_Loader::class, 'load_options' ); $load_options->invoke( $loader ); $loaded_value = $pdo->query( "SELECT option_value FROM wp_options WHERE option_name = 'cron'" )->fetchColumn(); diff --git a/tests/smoke-primary-cold-reconstruction.php b/tests/smoke-primary-cold-reconstruction.php index a7eebba..4c481da 100644 --- a/tests/smoke-primary-cold-reconstruction.php +++ b/tests/smoke-primary-cold-reconstruction.php @@ -22,7 +22,7 @@ class WP_MySQL_On_SQLite { private bool $in_transaction = false; public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->connection = new WP_SQLite_Connection( $options['pdo'] ); + $this->connection = new WP_SQLite_Connection( $options['sqlite_pdo'] ); } public function get_connection(): WP_SQLite_Connection { return $this->connection; } public function get_insert_id(): int { return (int) $this->connection->get_pdo()->lastInsertId(); } diff --git a/tests/smoke-primary-storage-runtime.php b/tests/smoke-primary-storage-runtime.php index aa55acd..b016775 100644 --- a/tests/smoke-primary-storage-runtime.php +++ b/tests/smoke-primary-storage-runtime.php @@ -23,7 +23,7 @@ class WP_MySQL_On_SQLite { private bool $in_transaction = false; public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->connection = new WP_SQLite_Connection( $options['pdo'] ); + $this->connection = new WP_SQLite_Connection( $options['sqlite_pdo'] ); } public function get_connection(): WP_SQLite_Connection { return $this->connection; } public function get_insert_id(): int { return (int) $this->connection->get_pdo()->lastInsertId(); } diff --git a/tests/smoke-state-root.php b/tests/smoke-state-root.php index 78a4218..8cb3f61 100644 --- a/tests/smoke-state-root.php +++ b/tests/smoke-state-root.php @@ -39,7 +39,7 @@ class WP_MySQL_On_SQLite { public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->connection = new WP_SQLite_Connection( $options['pdo'] ); + $this->connection = new WP_SQLite_Connection( $options['sqlite_pdo'] ); } public function get_connection(): WP_SQLite_Connection { @@ -139,7 +139,7 @@ function mdi_state_schema( PDO $pdo ): void { $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); mdi_state_schema( $pdo ); $connection = new WP_SQLite_Connection( $pdo ); -$driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'pdo' => $pdo ) ); +$driver = new WP_MySQL_On_SQLite( 'mysql-on-sqlite:dbname=wordpress', null, null, array( 'sqlite_pdo' => $pdo ) ); $storage = new WP_Markdown_Storage( $content_dir ); $loader = new WP_Markdown_Loader( $content_dir, $driver, $storage, 'wp_', $state_dir ); $pdo->exec( "INSERT INTO wp_terms (term_id, slug) VALUES (9, 'guides')" ); diff --git a/tests/smoke-streaming-table-persistence.php b/tests/smoke-streaming-table-persistence.php index 5abbd40..4ffa8ef 100644 --- a/tests/smoke-streaming-table-persistence.php +++ b/tests/smoke-streaming-table-persistence.php @@ -43,7 +43,8 @@ class MDI_Streaming_SQLite_Driver extends PDO { public function __construct( string $dsn, ?string $username = null, ?string $password = null, array $options = array() ) { unset( $dsn, $username, $password ); - $this->connection = new WP_SQLite_Connection( $options['pdo'] ); + $pdo_option = class_exists( 'WP_PDO_MySQL_On_SQLite', false ) ? 'pdo' : 'sqlite_pdo'; + $this->connection = new WP_SQLite_Connection( $options[ $pdo_option ] ); } #[ReturnTypeWillChange]