From 2e544c44542d4c2c93d64d8492b9ab0426b6d3b2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 10:26:33 +0000
Subject: [PATCH 1/6] Initial plan
From 347182e378704123c0ddc3feefb82deb9d4c1111 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 13:15:57 +0000
Subject: [PATCH 2/6] Add wp db users create command implementation
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
composer.json | 4 +-
db-command.php | 1 +
features/db-users.feature | 79 +++++++++++++
phpcs.xml.dist | 1 +
src/DB_Users_Command.php | 228 ++++++++++++++++++++++++++++++++++++++
5 files changed, 312 insertions(+), 1 deletion(-)
create mode 100644 features/db-users.feature
create mode 100644 src/DB_Users_Command.php
diff --git a/composer.json b/composer.json
index e575f01f..8d78d852 100644
--- a/composer.json
+++ b/composer.json
@@ -50,7 +50,9 @@
"db search",
"db tables",
"db size",
- "db columns"
+ "db columns",
+ "db users",
+ "db users create"
]
},
"autoload": {
diff --git a/db-command.php b/db-command.php
index d08f55bb..3af9a7ec 100644
--- a/db-command.php
+++ b/db-command.php
@@ -10,3 +10,4 @@
}
WP_CLI::add_command( 'db', 'DB_Command' );
+WP_CLI::add_command( 'db users', 'DB_Users_Command' );
diff --git a/features/db-users.feature b/features/db-users.feature
new file mode 100644
index 00000000..06e8d917
--- /dev/null
+++ b/features/db-users.feature
@@ -0,0 +1,79 @@
+Feature: Manage database users
+
+ Scenario: Create database user without privileges
+ Given an empty directory
+ And WP files
+ And wp-config.php
+
+ When I run `wp db create`
+ Then STDOUT should be:
+ """
+ Success: Database created.
+ """
+
+ When I run `wp db users create testuser localhost --password=testpass123`
+ Then STDOUT should contain:
+ """
+ Success: Database user 'testuser'@'localhost' created.
+ """
+
+ When I run `wp db query "SELECT User, Host FROM mysql.user WHERE User='testuser'"`
+ Then STDOUT should contain:
+ """
+ testuser
+ """
+
+ Scenario: Create database user with privileges
+ Given an empty directory
+ And WP files
+ And wp-config.php
+
+ When I run `wp db create`
+ Then STDOUT should be:
+ """
+ Success: Database created.
+ """
+
+ When I run `wp db users create appuser localhost --password=secret123 --grant-privileges`
+ Then STDOUT should contain:
+ """
+ created with privileges on database
+ """
+ And STDOUT should contain:
+ """
+ appuser
+ """
+
+ Scenario: Create database user with custom host
+ Given an empty directory
+ And WP files
+ And wp-config.php
+
+ When I run `wp db create`
+ Then STDOUT should be:
+ """
+ Success: Database created.
+ """
+
+ When I run `wp db users create remoteuser '%' --password=remote123`
+ Then STDOUT should contain:
+ """
+ Success: Database user 'remoteuser'@'%' created.
+ """
+
+ Scenario: Create database user with no password
+ Given an empty directory
+ And WP files
+ And wp-config.php
+
+ When I run `wp db create`
+ Then STDOUT should be:
+ """
+ Success: Database created.
+ """
+
+ When I run `wp db users create nopassuser localhost`
+ Then STDOUT should contain:
+ """
+ Success: Database user 'nopassuser'@'localhost' created.
+ """
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index c39cec40..6ab493d8 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -54,6 +54,7 @@
*/src/DB_Command\.php$
+ */src/DB_Users_Command\.php$
/tests/phpstan/scan-files
diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php
new file mode 100644
index 00000000..fb158223
--- /dev/null
+++ b/src/DB_Users_Command.php
@@ -0,0 +1,228 @@
+
+ * : MySQL username for the new user account.
+ *
+ * []
+ * : MySQL host for the new user account.
+ * ---
+ * default: localhost
+ * ---
+ *
+ * [--password=]
+ * : Password for the new user account. If not provided, MySQL will use no password.
+ *
+ * [--grant-privileges]
+ * : Grant full privileges on the current database to the new user.
+ *
+ * [--dbuser=]
+ * : Username to connect as (privileged user). Defaults to DB_USER.
+ *
+ * [--dbpass=]
+ * : Password to connect with (privileged user). Defaults to DB_PASSWORD.
+ *
+ * [--defaults]
+ * : Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration.
+ *
+ * ## EXAMPLES
+ *
+ * # Create a user without privileges.
+ * $ wp db users create myuser localhost --password=mypass
+ * Success: Database user 'myuser'@'localhost' created.
+ *
+ * # Create a user with full privileges on the current database.
+ * $ wp db users create appuser localhost --password=secret123 --grant-privileges
+ * Success: Database user 'appuser'@'localhost' created with privileges on database 'wp_database'.
+ */
+ public function create( $args, $assoc_args ) {
+ list( $username, $host ) = array_pad( $args, 2, 'localhost' );
+
+ $password = Utils\get_flag_value( $assoc_args, 'password', '' );
+ $grant_privileges = Utils\get_flag_value( $assoc_args, 'grant-privileges', false );
+
+ // Escape identifiers for SQL
+ $username_escaped = $this->esc_sql_ident( $username );
+ $host_escaped = $this->esc_sql_ident( $host );
+ $user_identifier = "{$username_escaped}@{$host_escaped}";
+
+ // Create user
+ $create_query = "CREATE USER {$user_identifier}";
+ if ( ! empty( $password ) ) {
+ $password_escaped = $this->esc_sql_string( $password );
+ $create_query .= " IDENTIFIED BY {$password_escaped}";
+ }
+ $create_query .= ';';
+
+ $this->run_query( $create_query, $assoc_args );
+
+ // Grant privileges if requested
+ if ( $grant_privileges ) {
+ $database = DB_NAME;
+ $database_escaped = $this->esc_sql_ident( $database );
+ $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
+ $this->run_query( $grant_query, $assoc_args );
+
+ // Flush privileges
+ $this->run_query( 'FLUSH PRIVILEGES;', $assoc_args );
+
+ WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." );
+ } else {
+ WP_CLI::success( "Database user '{$username}'@'{$host}' created." );
+ }
+ }
+
+ /**
+ * Run a single query via the 'mysql' binary.
+ *
+ * @param string $query Query to execute.
+ * @param array $assoc_args Optional. Associative array of arguments.
+ */
+ private function run_query( $query, $assoc_args = [] ) {
+ WP_CLI::debug( "Query: {$query}", 'db' );
+
+ $mysql_args = array_merge(
+ $this->get_dbuser_dbpass_args( $assoc_args ),
+ $this->get_mysql_args( $assoc_args )
+ );
+
+ $this->run(
+ sprintf(
+ 'mysql%s --no-auto-rehash',
+ $this->get_defaults_flag_string( $assoc_args )
+ ),
+ array_merge( [ 'execute' => $query ], $mysql_args )
+ );
+ }
+
+ /**
+ * Run a MySQL command.
+ *
+ * @param string $cmd Command to run.
+ * @param array $assoc_args Optional. Associative array of arguments to use.
+ *
+ * @return array {
+ * Associative array containing STDOUT and STDERR output.
+ *
+ * @type string $stdout Output that was sent to STDOUT.
+ * @type string $stderr Output that was sent to STDERR.
+ * @type int $exit_code Exit code of the process.
+ * }
+ */
+ private function run( $cmd, $assoc_args = [] ) {
+ $required = [
+ 'host' => DB_HOST,
+ 'user' => DB_USER,
+ 'pass' => DB_PASSWORD,
+ ];
+
+ if ( ! isset( $assoc_args['default-character-set'] )
+ && defined( 'DB_CHARSET' ) && constant( 'DB_CHARSET' ) ) {
+ $required['default-character-set'] = constant( 'DB_CHARSET' );
+ }
+
+ // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter.
+ if ( isset( $assoc_args['dbuser'] ) ) {
+ $required['user'] = $assoc_args['dbuser'];
+ unset( $assoc_args['dbuser'] );
+ }
+ if ( isset( $assoc_args['dbpass'] ) ) {
+ $required['pass'] = $assoc_args['dbpass'];
+ unset( $assoc_args['dbpass'], $assoc_args['password'] );
+ }
+
+ $final_args = array_merge( $required, $assoc_args );
+
+ return Utils\run_mysql_command( $cmd, $final_args, null, true, false );
+ }
+
+ /**
+ * Helper to pluck 'dbuser' and 'dbpass' from associative args array.
+ *
+ * @param array $assoc_args Associative args array.
+ * @return array Array with 'dbuser' and 'dbpass' set if in passed-in associative args array.
+ */
+ private function get_dbuser_dbpass_args( $assoc_args ) {
+ $mysql_args = [];
+ $dbuser = Utils\get_flag_value( $assoc_args, 'dbuser' );
+ if ( null !== $dbuser ) {
+ $mysql_args['dbuser'] = $dbuser;
+ }
+ $dbpass = Utils\get_flag_value( $assoc_args, 'dbpass' );
+ if ( null !== $dbpass ) {
+ $mysql_args['dbpass'] = $dbpass;
+ }
+ return $mysql_args;
+ }
+
+ /**
+ * Gets the MySQL args from the associative args array.
+ *
+ * @param array $assoc_args Associative args array.
+ * @return array MySQL args.
+ */
+ private function get_mysql_args( $assoc_args ) {
+ $mysql_args = [];
+
+ if ( isset( $assoc_args['host'] ) ) {
+ $mysql_args['host'] = $assoc_args['host'];
+ }
+
+ return $mysql_args;
+ }
+
+ /**
+ * Gets the defaults flag string.
+ *
+ * @param array $assoc_args Associative args array.
+ * @return string Defaults flag string.
+ */
+ private function get_defaults_flag_string( $assoc_args ) {
+ $defaults = Utils\get_flag_value( $assoc_args, 'defaults', false );
+ return $defaults ? '' : ' --no-defaults';
+ }
+
+ /**
+ * Escapes a string for use in a SQL query.
+ *
+ * @param string $value String to escape.
+ * @return string Escaped string.
+ */
+ private function esc_sql_string( $value ) {
+ // Use single quotes and escape single quotes by doubling them.
+ return "'" . str_replace( "'", "''", $value ) . "'";
+ }
+
+ /**
+ * Escapes (backticks) MySQL identifiers (aka schema object names).
+ *
+ * @param string $ident A single identifier.
+ * @return string An escaped string.
+ */
+ private function esc_sql_ident( $ident ) {
+ // Escape any backticks in the identifier by doubling.
+ return '`' . str_replace( '`', '``', $ident ) . '`';
+ }
+}
From 891f7139ece4eda192c03c08259945772465b9c2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 13:36:22 +0000
Subject: [PATCH 3/6] Refactor DB_Users_Command to extend DB_Command and
improve security
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/DB_Users_Command.php | 121 +++++----------------------------------
test-output.txt | 45 +++++++++++++++
2 files changed, 58 insertions(+), 108 deletions(-)
create mode 100644 test-output.txt
diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php
index fb158223..2d979fb5 100644
--- a/src/DB_Users_Command.php
+++ b/src/DB_Users_Command.php
@@ -13,7 +13,7 @@
*
* @when after_wp_config_load
*/
-class DB_Users_Command extends WP_CLI_Command {
+class DB_Users_Command extends DB_Command {
/**
* Creates a new database user with optional privileges.
@@ -76,17 +76,17 @@ public function create( $args, $assoc_args ) {
}
$create_query .= ';';
- $this->run_query( $create_query, $assoc_args );
+ $this->run_user_query( $create_query, $assoc_args );
// Grant privileges if requested
if ( $grant_privileges ) {
$database = DB_NAME;
$database_escaped = $this->esc_sql_ident( $database );
$grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
- $this->run_query( $grant_query, $assoc_args );
+ $this->run_user_query( $grant_query, $assoc_args );
// Flush privileges
- $this->run_query( 'FLUSH PRIVILEGES;', $assoc_args );
+ $this->run_user_query( 'FLUSH PRIVILEGES;', $assoc_args );
WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." );
} else {
@@ -97,111 +97,14 @@ public function create( $args, $assoc_args ) {
/**
* Run a single query via the 'mysql' binary.
*
+ * This method adds SQL mode compatibility and delegates to the parent class.
+ *
* @param string $query Query to execute.
* @param array $assoc_args Optional. Associative array of arguments.
*/
- private function run_query( $query, $assoc_args = [] ) {
- WP_CLI::debug( "Query: {$query}", 'db' );
-
- $mysql_args = array_merge(
- $this->get_dbuser_dbpass_args( $assoc_args ),
- $this->get_mysql_args( $assoc_args )
- );
-
- $this->run(
- sprintf(
- 'mysql%s --no-auto-rehash',
- $this->get_defaults_flag_string( $assoc_args )
- ),
- array_merge( [ 'execute' => $query ], $mysql_args )
- );
- }
-
- /**
- * Run a MySQL command.
- *
- * @param string $cmd Command to run.
- * @param array $assoc_args Optional. Associative array of arguments to use.
- *
- * @return array {
- * Associative array containing STDOUT and STDERR output.
- *
- * @type string $stdout Output that was sent to STDOUT.
- * @type string $stderr Output that was sent to STDERR.
- * @type int $exit_code Exit code of the process.
- * }
- */
- private function run( $cmd, $assoc_args = [] ) {
- $required = [
- 'host' => DB_HOST,
- 'user' => DB_USER,
- 'pass' => DB_PASSWORD,
- ];
-
- if ( ! isset( $assoc_args['default-character-set'] )
- && defined( 'DB_CHARSET' ) && constant( 'DB_CHARSET' ) ) {
- $required['default-character-set'] = constant( 'DB_CHARSET' );
- }
-
- // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter.
- if ( isset( $assoc_args['dbuser'] ) ) {
- $required['user'] = $assoc_args['dbuser'];
- unset( $assoc_args['dbuser'] );
- }
- if ( isset( $assoc_args['dbpass'] ) ) {
- $required['pass'] = $assoc_args['dbpass'];
- unset( $assoc_args['dbpass'], $assoc_args['password'] );
- }
-
- $final_args = array_merge( $required, $assoc_args );
-
- return Utils\run_mysql_command( $cmd, $final_args, null, true, false );
- }
-
- /**
- * Helper to pluck 'dbuser' and 'dbpass' from associative args array.
- *
- * @param array $assoc_args Associative args array.
- * @return array Array with 'dbuser' and 'dbpass' set if in passed-in associative args array.
- */
- private function get_dbuser_dbpass_args( $assoc_args ) {
- $mysql_args = [];
- $dbuser = Utils\get_flag_value( $assoc_args, 'dbuser' );
- if ( null !== $dbuser ) {
- $mysql_args['dbuser'] = $dbuser;
- }
- $dbpass = Utils\get_flag_value( $assoc_args, 'dbpass' );
- if ( null !== $dbpass ) {
- $mysql_args['dbpass'] = $dbpass;
- }
- return $mysql_args;
- }
-
- /**
- * Gets the MySQL args from the associative args array.
- *
- * @param array $assoc_args Associative args array.
- * @return array MySQL args.
- */
- private function get_mysql_args( $assoc_args ) {
- $mysql_args = [];
-
- if ( isset( $assoc_args['host'] ) ) {
- $mysql_args['host'] = $assoc_args['host'];
- }
-
- return $mysql_args;
- }
-
- /**
- * Gets the defaults flag string.
- *
- * @param array $assoc_args Associative args array.
- * @return string Defaults flag string.
- */
- private function get_defaults_flag_string( $assoc_args ) {
- $defaults = Utils\get_flag_value( $assoc_args, 'defaults', false );
- return $defaults ? '' : ' --no-defaults';
+ private function run_user_query( $query, $assoc_args = [] ) {
+ // Use parent class's run_query method which handles SQL mode compatibility.
+ parent::run_query( $query, $assoc_args );
}
/**
@@ -211,8 +114,10 @@ private function get_defaults_flag_string( $assoc_args ) {
* @return string Escaped string.
*/
private function esc_sql_string( $value ) {
- // Use single quotes and escape single quotes by doubling them.
- return "'" . str_replace( "'", "''", $value ) . "'";
+ // Escape backslashes first, then single quotes.
+ $value = str_replace( '\\', '\\\\', $value );
+ $value = str_replace( "'", "''", $value );
+ return "'" . $value . "'";
}
/**
diff --git a/test-output.txt b/test-output.txt
new file mode 100644
index 00000000..cef192a4
--- /dev/null
+++ b/test-output.txt
@@ -0,0 +1,45 @@
+WP_CLI_TEST_DBTYPE is set to 'sqlite', skipping database check.
+.F-------.F------.F-----.F-----
+
+--- Failed steps:
+
+001 Scenario: Create database user without privileges # features/db-users.feature:3
+ And WP files # features/db-users.feature:5
+ $ curl -sSfL 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip' > '/tmp/wp-cli-test-sqlite-integration-cache/sqlite-database-integration.zip'
+
+ curl: (6) Could not resolve host: downloads.wordpress.org
+ cwd:
+ run time: 0.0067920684814453
+ exit status: 6 (RuntimeException)
+
+002 Scenario: Create database user with privileges # features/db-users.feature:26
+ And WP files # features/db-users.feature:28
+ $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f102bc5.10488262/'
+
+ cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
+ cwd:
+ run time: 0.0016000270843506
+ exit status: 1 (RuntimeException)
+
+003 Scenario: Create database user with custom host # features/db-users.feature:47
+ And WP files # features/db-users.feature:49
+ $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f10e798.58842661/'
+
+ cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
+ cwd:
+ run time: 0.001478910446167
+ exit status: 1 (RuntimeException)
+
+004 Scenario: Create database user with no password # features/db-users.feature:64
+ And WP files # features/db-users.feature:66
+ $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f118931.57377979/'
+
+ cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
+ cwd:
+ run time: 0.0014328956604004
+ exit status: 1 (RuntimeException)
+
+4 scenarios (4 failed)
+31 steps (4 passed, 4 failed, 23 skipped)
+0m0.21s (12.58Mb)
+Script run-behat-tests handling the behat event returned with error code 1
From 7f2d7d9b210fbf6b127d74d7bf8ea6d46dac2605 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 13:43:43 +0000
Subject: [PATCH 4/6] Simplify code by calling parent::run_query directly and
remove test-output.txt
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/DB_Users_Command.php | 22 ++++++--------------
test-output.txt | 45 ----------------------------------------
2 files changed, 6 insertions(+), 61 deletions(-)
delete mode 100644 test-output.txt
diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php
index 2d979fb5..f6c6737c 100644
--- a/src/DB_Users_Command.php
+++ b/src/DB_Users_Command.php
@@ -76,17 +76,17 @@ public function create( $args, $assoc_args ) {
}
$create_query .= ';';
- $this->run_user_query( $create_query, $assoc_args );
+ parent::run_query( $create_query, $assoc_args );
// Grant privileges if requested
if ( $grant_privileges ) {
$database = DB_NAME;
$database_escaped = $this->esc_sql_ident( $database );
$grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
- $this->run_user_query( $grant_query, $assoc_args );
+ parent::run_query( $grant_query, $assoc_args );
// Flush privileges
- $this->run_user_query( 'FLUSH PRIVILEGES;', $assoc_args );
+ parent::run_query( 'FLUSH PRIVILEGES;', $assoc_args );
WP_CLI::success( "Database user '{$username}'@'{$host}' created with privileges on database '{$database}'." );
} else {
@@ -94,19 +94,6 @@ public function create( $args, $assoc_args ) {
}
}
- /**
- * Run a single query via the 'mysql' binary.
- *
- * This method adds SQL mode compatibility and delegates to the parent class.
- *
- * @param string $query Query to execute.
- * @param array $assoc_args Optional. Associative array of arguments.
- */
- private function run_user_query( $query, $assoc_args = [] ) {
- // Use parent class's run_query method which handles SQL mode compatibility.
- parent::run_query( $query, $assoc_args );
- }
-
/**
* Escapes a string for use in a SQL query.
*
@@ -123,6 +110,9 @@ private function esc_sql_string( $value ) {
/**
* Escapes (backticks) MySQL identifiers (aka schema object names).
*
+ * Note: This duplicates functionality from parent DB_Command::esc_sql_ident()
+ * which is private static and cannot be accessed from child classes.
+ *
* @param string $ident A single identifier.
* @return string An escaped string.
*/
diff --git a/test-output.txt b/test-output.txt
deleted file mode 100644
index cef192a4..00000000
--- a/test-output.txt
+++ /dev/null
@@ -1,45 +0,0 @@
-WP_CLI_TEST_DBTYPE is set to 'sqlite', skipping database check.
-.F-------.F------.F-----.F-----
-
---- Failed steps:
-
-001 Scenario: Create database user without privileges # features/db-users.feature:3
- And WP files # features/db-users.feature:5
- $ curl -sSfL 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip' > '/tmp/wp-cli-test-sqlite-integration-cache/sqlite-database-integration.zip'
-
- curl: (6) Could not resolve host: downloads.wordpress.org
- cwd:
- run time: 0.0067920684814453
- exit status: 6 (RuntimeException)
-
-002 Scenario: Create database user with privileges # features/db-users.feature:26
- And WP files # features/db-users.feature:28
- $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f102bc5.10488262/'
-
- cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
- cwd:
- run time: 0.0016000270843506
- exit status: 1 (RuntimeException)
-
-003 Scenario: Create database user with custom host # features/db-users.feature:47
- And WP files # features/db-users.feature:49
- $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f10e798.58842661/'
-
- cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
- cwd:
- run time: 0.001478910446167
- exit status: 1 (RuntimeException)
-
-004 Scenario: Create database user with no password # features/db-users.feature:64
- And WP files # features/db-users.feature:66
- $ cp -r '/tmp/wp-cli-test-core-download-cache'/* '/tmp/wp-cli-test-run--696e307f118931.57377979/'
-
- cp: cannot stat '/tmp/wp-cli-test-core-download-cache/*': No such file or directory
- cwd:
- run time: 0.0014328956604004
- exit status: 1 (RuntimeException)
-
-4 scenarios (4 failed)
-31 steps (4 passed, 4 failed, 23 skipped)
-0m0.21s (12.58Mb)
-Script run-behat-tests handling the behat event returned with error code 1
From 84b425ff7caca7a1ade1fd56fbb67daa0758a38c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 15:15:02 +0000
Subject: [PATCH 5/6] Make esc_sql_ident protected in parent class and call it
statically
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/DB_Command.php | 2 +-
src/DB_Users_Command.php | 27 ++++++++-------------------
2 files changed, 9 insertions(+), 20 deletions(-)
diff --git a/src/DB_Command.php b/src/DB_Command.php
index 06781cee..25992fd3 100644
--- a/src/DB_Command.php
+++ b/src/DB_Command.php
@@ -1923,7 +1923,7 @@ private static function is_text_col( $type ) {
*
* @phpstan-return ($idents is string ? string : array)
*/
- private static function esc_sql_ident( $idents ) {
+ protected static function esc_sql_ident( $idents ) {
$backtick = static function ( $v ) {
// Escape any backticks in the identifier by doubling.
return '`' . str_replace( '`', '``', $v ) . '`';
diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php
index f6c6737c..7699fc76 100644
--- a/src/DB_Users_Command.php
+++ b/src/DB_Users_Command.php
@@ -64,9 +64,11 @@ public function create( $args, $assoc_args ) {
$grant_privileges = Utils\get_flag_value( $assoc_args, 'grant-privileges', false );
// Escape identifiers for SQL
- $username_escaped = $this->esc_sql_ident( $username );
- $host_escaped = $this->esc_sql_ident( $host );
- $user_identifier = "{$username_escaped}@{$host_escaped}";
+ $username_escaped = self::esc_sql_ident( $username );
+ $host_escaped = self::esc_sql_ident( $host );
+ assert( is_string( $username_escaped ) );
+ assert( is_string( $host_escaped ) );
+ $user_identifier = "{$username_escaped}@{$host_escaped}";
// Create user
$create_query = "CREATE USER {$user_identifier}";
@@ -81,8 +83,9 @@ public function create( $args, $assoc_args ) {
// Grant privileges if requested
if ( $grant_privileges ) {
$database = DB_NAME;
- $database_escaped = $this->esc_sql_ident( $database );
- $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
+ $database_escaped = self::esc_sql_ident( $database );
+ assert( is_string( $database_escaped ) );
+ $grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
parent::run_query( $grant_query, $assoc_args );
// Flush privileges
@@ -106,18 +109,4 @@ private function esc_sql_string( $value ) {
$value = str_replace( "'", "''", $value );
return "'" . $value . "'";
}
-
- /**
- * Escapes (backticks) MySQL identifiers (aka schema object names).
- *
- * Note: This duplicates functionality from parent DB_Command::esc_sql_ident()
- * which is private static and cannot be accessed from child classes.
- *
- * @param string $ident A single identifier.
- * @return string An escaped string.
- */
- private function esc_sql_ident( $ident ) {
- // Escape any backticks in the identifier by doubling.
- return '`' . str_replace( '`', '``', $ident ) . '`';
- }
}
From 421a9a4ef08a7dc76f4777625de6734953faab55 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 Jan 2026 16:24:24 +0000
Subject: [PATCH 6/6] Remove assert calls and improve SQL string escaping for
security
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/DB_Users_Command.php | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/DB_Users_Command.php b/src/DB_Users_Command.php
index 7699fc76..f5fad023 100644
--- a/src/DB_Users_Command.php
+++ b/src/DB_Users_Command.php
@@ -66,8 +66,8 @@ public function create( $args, $assoc_args ) {
// Escape identifiers for SQL
$username_escaped = self::esc_sql_ident( $username );
$host_escaped = self::esc_sql_ident( $host );
- assert( is_string( $username_escaped ) );
- assert( is_string( $host_escaped ) );
+ /** @var string $username_escaped */
+ /** @var string $host_escaped */
$user_identifier = "{$username_escaped}@{$host_escaped}";
// Create user
@@ -84,7 +84,7 @@ public function create( $args, $assoc_args ) {
if ( $grant_privileges ) {
$database = DB_NAME;
$database_escaped = self::esc_sql_ident( $database );
- assert( is_string( $database_escaped ) );
+ /** @var string $database_escaped */
$grant_query = "GRANT ALL PRIVILEGES ON {$database_escaped}.* TO {$user_identifier};";
parent::run_query( $grant_query, $assoc_args );
@@ -100,13 +100,19 @@ public function create( $args, $assoc_args ) {
/**
* Escapes a string for use in a SQL query.
*
+ * Follows MySQL's documented string literal escaping rules.
+ * See https://dev.mysql.com/doc/refman/en/string-literals.html
+ *
* @param string $value String to escape.
- * @return string Escaped string.
+ * @return string Escaped string, wrapped in single quotes.
*/
private function esc_sql_string( $value ) {
- // Escape backslashes first, then single quotes.
- $value = str_replace( '\\', '\\\\', $value );
- $value = str_replace( "'", "''", $value );
+ // Escape special characters according to MySQL string literal rules.
+ $value = str_replace(
+ [ '\\', "\x00", "\n", "\r", "'", '"', "\x1a" ],
+ [ '\\\\', "\\0", "\\n", "\\r", "\\'", '\\"', '\\Z' ],
+ $value
+ );
return "'" . $value . "'";
}
}