diff --git a/.gitattributes b/.gitattributes index 41f7464..6a7e147 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,4 @@ phpunit.xml.dist export-ignore /.github export-ignore /grammar-tools export-ignore /tests export-ignore -/wip export-ignore -/wp-includes/mysql export-ignore -/wp-includes/parser export-ignore /wp-includes/sqlite/class-wp-sqlite-crosscheck-db.php export-ignore diff --git a/activate.php b/activate.php index e91e181..197f162 100644 --- a/activate.php +++ b/activate.php @@ -75,8 +75,8 @@ function ( $result ) { * When the plugin gets merged in wp-core, this is not to be ported. */ function sqlite_plugin_copy_db_file() { - // Bail early if the SQLite3 class does not exist. - if ( ! class_exists( 'SQLite3' ) ) { + // Bail early if the PDO SQLite extension is not loaded. + if ( ! extension_loaded( 'pdo_sqlite' ) ) { return; } diff --git a/admin-notices.php b/admin-notices.php index 8eaf253..a455cc8 100644 --- a/admin-notices.php +++ b/admin-notices.php @@ -19,11 +19,11 @@ function sqlite_plugin_admin_notice() { return; } - // If SQLite is not detected, bail early. - if ( ! class_exists( 'SQLite3' ) ) { + // If PDO SQLite is not loaded, bail early. + if ( ! extension_loaded( 'pdo_sqlite' ) ) { printf( '
%s
WordPress database error: [%1$s] %2$s
%s [%s]%s
' . $query . '
'
+ $query
);
}
}
@@ -229,8 +233,35 @@ public function db_connect( $allow_bail = true ) {
if ( isset( $GLOBALS['@pdo'] ) ) {
$pdo = $GLOBALS['@pdo'];
}
- $this->dbh = new WP_SQLite_Translator( $pdo );
- $this->last_error = $this->dbh->get_error_message();
+ if ( defined( 'WP_SQLITE_AST_DRIVER' ) && WP_SQLITE_AST_DRIVER ) {
+ require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-grammar.php';
+ require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser.php';
+ require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-node.php';
+ require_once __DIR__ . '/../../wp-includes/parser/class-wp-parser-token.php';
+ require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-token.php';
+ require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-lexer.php';
+ require_once __DIR__ . '/../../wp-includes/mysql/class-wp-mysql-parser.php';
+ require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-driver.php';
+ require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php';
+ require_once __DIR__ . '/../../wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php';
+ $this->ensure_database_directory( FQDB );
+
+ try {
+ $this->dbh = new WP_SQLite_Driver(
+ array(
+ 'connection' => $pdo,
+ 'path' => FQDB,
+ 'database' => $this->dbname,
+ 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null,
+ )
+ );
+ } catch ( Throwable $e ) {
+ $this->last_error = $this->format_error_message( $e );
+ }
+ } else {
+ $this->dbh = new WP_SQLite_Translator( $pdo );
+ $this->last_error = $this->dbh->get_error_message();
+ }
if ( $this->last_error ) {
return false;
}
@@ -250,16 +281,17 @@ public function check_connection( $allow_bail = true ) {
}
/**
- * Method to execute the query.
+ * Performs a database query.
*
- * This overrides wpdb::query(). In fact, this method does all the database
- * access jobs.
+ * This overrides wpdb::query() while closely mirroring its implementation.
*
* @see wpdb::query()
*
* @param string $query Database query.
*
- * @return int|false Number of rows affected/selected or false on error
+ * @param string $query Database query.
+ * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows
+ * affected/selected for all other queries. Boolean false on error.
*/
public function query( $query ) {
if ( ! $this->ready ) {
@@ -268,43 +300,108 @@ public function query( $query ) {
$query = apply_filters( 'query', $query );
- $this->flush();
+ if ( ! $query ) {
+ $this->insert_id = 0;
+ return false;
+ }
- $this->func_call = "\$db->query(\"$query\")";
- $this->last_query = $query;
+ $this->flush();
- if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
- $this->timer_start();
- }
+ // Log how the function was called.
+ $this->func_call = "\$db->query(\"$query\")";
- $this->result = $this->dbh->query( $query );
- ++$this->num_queries;
+ // Keep track of the last query for debug.
+ $this->last_query = $query;
- if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
- $this->queries[] = array( $query, $this->timer_stop(), $this->get_caller() );
- }
+ /*
+ * @TODO: WPDB uses "$this->check_current_query" to check table/column
+ * charset and strip all invalid characters from the query.
+ * This is an involved process that we can bypass for SQLite,
+ * if we simply strip all invalid UTF-8 characters from the query.
+ *
+ * To do so, mb_convert_encoding can be used with an optional
+ * fallback to a htmlspecialchars method. E.g.:
+ * https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42
+ */
+ $this->_do_query( $query );
- $this->last_error = $this->dbh->get_error_message();
if ( $this->last_error ) {
- $this->print_error( $this->last_error );
+ // Clear insert_id on a subsequent failed insert.
+ if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
+ $this->insert_id = 0;
+ }
+
+ $this->print_error();
return false;
}
- if ( preg_match( '/^\\s*(set|create|alter|truncate|drop|optimize)\\s*/i', $query ) ) {
- return $this->dbh->get_return_value();
- }
+ if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) {
+ $return_val = true;
+ } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) {
+ if ( $this->dbh instanceof WP_SQLite_Driver ) {
+ $this->rows_affected = $this->dbh->get_last_return_value();
+ } else {
+ $this->rows_affected = $this->dbh->get_rows_affected();
+ }
- if ( preg_match( '/^\\s*(insert|delete|update|replace)\s/i', $query ) ) {
- $this->rows_affected = $this->dbh->get_affected_rows();
+ // Take note of the insert_id.
if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) {
$this->insert_id = $this->dbh->get_insert_id();
}
- return $this->rows_affected;
+
+ // Return number of rows affected.
+ $return_val = $this->rows_affected;
+ } else {
+ $num_rows = 0;
+
+ if ( is_array( $this->result ) ) {
+ $this->last_result = $this->result;
+ $num_rows = count( $this->result );
+ }
+
+ // Log and return the number of rows selected.
+ $this->num_rows = $num_rows;
+ $return_val = $num_rows;
+ }
+
+ return $return_val;
+ }
+
+ /**
+ * Internal function to perform the SQLite query call.
+ *
+ * This closely mirrors wpdb::_do_query().
+ *
+ * @see wpdb::_do_query()
+ *
+ * @param string $query The query to run.
+ */
+ private function _do_query( $query ) {
+ if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
+ $this->timer_start();
+ }
+
+ try {
+ $this->result = $this->dbh->query( $query );
+ } catch ( Throwable $e ) {
+ $this->last_error = $this->format_error_message( $e );
+ }
+
+ if ( $this->dbh instanceof WP_SQLite_Translator ) {
+ $this->last_error = $this->dbh->get_error_message();
}
- $this->last_result = $this->dbh->get_query_results();
- $this->num_rows = $this->dbh->get_num_rows();
- return $this->num_rows;
+ ++$this->num_queries;
+
+ if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
+ $this->log_query(
+ $query,
+ $this->timer_stop(),
+ $this->get_caller(),
+ $this->time_start,
+ array()
+ );
+ }
}
/**
@@ -353,11 +450,95 @@ public function db_version() {
}
/**
- * Retrieves full database server information.
+ * Returns the version of the SQLite engine.
*
- * @return string|false Server info on success, false on failure.
+ * @return string SQLite engine version as a string.
*/
public function db_server_info() {
- return SQLite3::version()['versionString'];
+ return $this->dbh->get_sqlite_version();
+ }
+
+ /**
+ * Make sure the SQLite database directory exists and is writable.
+ * Create .htaccess and index.php files to prevent direct access.
+ *
+ * @param string $database_path The path to the SQLite database file.
+ */
+ private function ensure_database_directory( string $database_path ) {
+ $dir = dirname( $database_path );
+
+ // Set the umask to 0000 to apply permissions exactly as specified.
+ // A non-zero umask affects new file and directory permissions.
+ $umask = umask( 0 );
+
+ // Ensure database directory.
+ if ( ! is_dir( $dir ) ) {
+ if ( ! @mkdir( $dir, 0700, true ) ) {
+ wp_die( sprintf( 'Failed to create database directory: %s', $dir ), 'Error!' );
+ }
+ }
+ if ( ! is_writable( $dir ) ) {
+ wp_die( sprintf( 'Database directory is not writable: %s', $dir ), 'Error!' );
+ }
+
+ // Ensure .htaccess file to prevent direct access.
+ $path = $dir . DIRECTORY_SEPARATOR . '.htaccess';
+ if ( ! is_file( $path ) ) {
+ $result = file_put_contents( $path, 'DENY FROM ALL', LOCK_EX );
+ if ( false === $result ) {
+ wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
+ }
+ chmod( $path, 0600 );
+ }
+
+ // Ensure index.php file to prevent direct access.
+ $path = $dir . DIRECTORY_SEPARATOR . 'index.php';
+ if ( ! is_file( $path ) ) {
+ $result = file_put_contents( $path, '', LOCK_EX );
+ if ( false === $result ) {
+ wp_die( sprintf( 'Failed to create file: %s', $path ), 'Error!' );
+ }
+ chmod( $path, 0600 );
+ }
+
+ // Restore the original umask value.
+ umask( $umask );
+ }
+
+
+ /**
+ * Format SQLite driver error message.
+ *
+ * @return string
+ */
+ private function format_error_message( Throwable $e ) {
+ $output = 'MySQL query:
' . PHP_EOL; + $output .= '' . $driver->get_last_mysql_query() . '
' . PHP_EOL; + $output .= 'Queries made or created this session were:
' . PHP_EOL; + $output .= 'Backtrace:
' . PHP_EOL; + $output .= '' . $e->getTraceAsString() . '' . PHP_EOL; + return $output; } } diff --git a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 6f0d83d..d14978b 100644 --- a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -23,19 +23,17 @@ class WP_SQLite_PDO_User_Defined_Functions { /** - * The class constructor - * - * Initializes the use defined functions to PDO object with PDO::sqliteCreateFunction(). + * Registers the user defined functions for SQLite to a PDO instance. + * The functions are registered using PDO::sqliteCreateFunction(). * * @param PDO $pdo The PDO object. */ - public function __construct( $pdo ) { - if ( ! $pdo ) { - wp_die( 'Database is not initialized.', 'Database Error' ); - } - foreach ( $this->functions as $f => $t ) { - $pdo->sqliteCreateFunction( $f, array( $this, $t ) ); + public static function register_for( PDO $pdo ): self { + $instance = new self(); + foreach ( $instance->functions as $f => $t ) { + $pdo->sqliteCreateFunction( $f, array( $instance, $t ) ); } + return $instance; } /** @@ -46,45 +44,48 @@ public function __construct( $pdo ) { * @var array */ private $functions = array( - 'month' => 'month', - 'monthnum' => 'month', - 'year' => 'year', - 'day' => 'day', - 'hour' => 'hour', - 'minute' => 'minute', - 'second' => 'second', - 'week' => 'week', - 'weekday' => 'weekday', - 'dayofweek' => 'dayofweek', - 'dayofmonth' => 'dayofmonth', - 'unix_timestamp' => 'unix_timestamp', - 'now' => 'now', - 'md5' => 'md5', - 'curdate' => 'curdate', - 'rand' => 'rand', - 'from_unixtime' => 'from_unixtime', - 'localtime' => 'now', - 'localtimestamp' => 'now', - 'isnull' => 'isnull', - 'if' => '_if', - 'regexp' => 'regexp', - 'field' => 'field', - 'log' => 'log', - 'least' => 'least', - 'greatest' => 'greatest', - 'get_lock' => 'get_lock', - 'release_lock' => 'release_lock', - 'ucase' => 'ucase', - 'lcase' => 'lcase', - 'unhex' => 'unhex', - 'inet_ntoa' => 'inet_ntoa', - 'inet_aton' => 'inet_aton', - 'datediff' => 'datediff', - 'locate' => 'locate', - 'utc_date' => 'utc_date', - 'utc_time' => 'utc_time', - 'utc_timestamp' => 'utc_timestamp', - 'version' => 'version', + 'month' => 'month', + 'monthnum' => 'month', + 'year' => 'year', + 'day' => 'day', + 'hour' => 'hour', + 'minute' => 'minute', + 'second' => 'second', + 'week' => 'week', + 'weekday' => 'weekday', + 'dayofweek' => 'dayofweek', + 'dayofmonth' => 'dayofmonth', + 'unix_timestamp' => 'unix_timestamp', + 'now' => 'now', + 'md5' => 'md5', + 'curdate' => 'curdate', + 'rand' => 'rand', + 'from_unixtime' => 'from_unixtime', + 'localtime' => 'now', + 'localtimestamp' => 'now', + 'isnull' => 'isnull', + 'if' => '_if', + 'regexp' => 'regexp', + 'field' => 'field', + 'log' => 'log', + 'least' => 'least', + 'greatest' => 'greatest', + 'get_lock' => 'get_lock', + 'release_lock' => 'release_lock', + 'ucase' => 'ucase', + 'lcase' => 'lcase', + 'unhex' => 'unhex', + 'inet_ntoa' => 'inet_ntoa', + 'inet_aton' => 'inet_aton', + 'datediff' => 'datediff', + 'locate' => 'locate', + 'utc_date' => 'utc_date', + 'utc_time' => 'utc_time', + 'utc_timestamp' => 'utc_timestamp', + 'version' => 'version', + + // Internal helper functions. + '_helper_like_to_glob_pattern' => '_helper_like_to_glob_pattern', ); /** @@ -759,4 +760,60 @@ public function utc_timestamp() { public function version() { return '5.5'; } + + /** + * A helper to covert LIKE pattern to a GLOB pattern for "LIKE BINARY" support. + + * @TODO: Some of the MySQL string specifics described below are likely to + * affect also other patterns than just "LIKE BINARY". We should + * consider applying some of the conversions more broadly. + * + * @param string $pattern + * @return string + */ + public function _helper_like_to_glob_pattern( $pattern ) { + if ( null === $pattern ) { + return null; + } + + /* + * 1. Escape characters that have special meaning in GLOB patterns. + * + * We need to: + * 1. Escape "]" as "[]]" to avoid interpreting "[...]" as a character class. + * 2. Escape "*" as "[*]" (must be after 1 to avoid being escaped). + * 3. Escape "?" as "[?]" (must be after 1 to avoid being escaped). + */ + $pattern = str_replace( ']', '[]]', $pattern ); + $pattern = str_replace( '*', '[*]', $pattern ); + $pattern = str_replace( '?', '[?]', $pattern ); + + /* + * 2. Convert LIKE wildcards to GLOB wildcards ("%" -> "*", "_" -> "?"). + * + * We need to convert them only when they don't follow any backslashes, + * or when they follow an even number of backslashes (as "\\" is "\"). + */ + $pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)%/', '$1*', $pattern ); + $pattern = preg_replace( '/(^|[^\\\\](?:\\\\{2})*)_/', '$1?', $pattern ); + + /* + * 3. Unescape LIKE escape sequences. + * + * While in MySQL LIKE patterns, a backslash is usually used to escape + * special characters ("%", "_", and "\"), it works with all characters. + * + * That is: + * SELECT '\\x' prints '\x', but LIKE '\\x' is equivalent to LIKE 'x'. + * + * This is true also for multi-byte characters: + * SELECT '\\©' prints '\©', but LIKE '\\©' is equivalent to LIKE '©'. + * + * However, the multi-byte behavior is likely to depend on the charset. + * For now, we'll assume UTF-8 and thus the "u" modifier for the regex. + */ + $pattern = preg_replace( '/\\\\(.)/u', '$1', $pattern ); + + return $pattern; + } } diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 44996cd..c353668 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -394,7 +394,7 @@ public function __construct( $pdo = null ) { } } - new WP_SQLite_PDO_User_Defined_Functions( $pdo ); + WP_SQLite_PDO_User_Defined_Functions::register_for( $pdo ); // MySQL data comes across stringified by default. $pdo->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO @@ -410,7 +410,7 @@ public function __construct( $pdo = null ) { $this->pdo = $pdo; // Fixes a warning in the site-health screen. - $this->client_info = SQLite3::version()['versionString']; + $this->client_info = $this->get_sqlite_version(); register_shutdown_function( array( $this, '__destruct' ) ); @@ -474,6 +474,15 @@ public function get_pdo() { return $this->pdo; } + /** + * Get the version of the SQLite engine. + * + * @return string SQLite engine version as a string. + */ + public function get_sqlite_version(): string { + return $this->pdo->query( 'SELECT SQLITE_VERSION()' )->fetchColumn(); + } + /** * Method to return inserted row id. */