From 5368a35227387e41d5e411ab23cff2d2192c8be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Tue, 11 Aug 2026 20:37:11 +0200 Subject: [PATCH 1/2] Reject invalid BIT column defaults Require exact BIT and hexadecimal literal forms during normalization. Reject values that cannot be normalized with MySQL error 1067 so arbitrary strings cannot be stored and emitted as SQL. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 8 +++ ...s-wp-sqlite-information-schema-builder.php | 46 +++++++++----- ...wp-sqlite-information-schema-exception.php | 15 +++++ .../tests/WP_MySQL_On_SQLite_Tests.php | 62 +++++++++++++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index c2644d145..d92b7e9dc 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -7908,6 +7908,14 @@ private function convert_information_schema_exception( WP_SQLite_Information_Sch null, array( '42000', 1072, $driver_message ) ); + case WP_SQLite_Information_Schema_Exception::TYPE_INVALID_DEFAULT_VALUE: + $driver_message = sprintf( "Invalid default value for '%s'", $e->get_data()['column_name'] ); + return $this->new_driver_exception( + 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message, + '42000', + null, + array( '42000', 1067, $driver_message ) + ); case WP_SQLite_Information_Schema_Exception::TYPE_CONSTRAINT_DOES_NOT_EXIST: $driver_message = sprintf( "Constraint '%s' does not exist.", $e->get_data()['name'] ); return $this->new_driver_exception( diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index b113f293b..2847aa8f2 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -1507,7 +1507,7 @@ private function record_drop_check_constraint( private function extract_column_data( string $table_name, string $column_name, WP_Parser_Node $node, int $position ): array { list ( $data_type, $column_type ) = $this->get_column_data_types( $node ); - $default = $this->get_column_default( $node, $data_type ); + $default = $this->get_column_default( $node, $data_type, $column_name ); $nullable = $this->get_column_nullable( $node ); $key = $this->get_column_key( $node ); $extra = $this->get_column_extra( $node ); @@ -2043,11 +2043,12 @@ private function get_table_comment( WP_Parser_Node $node ): string { /** * Extract column default value from the "columnDefinition" or "fieldDefinition" AST node. * - * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. - * @param string $data_type The column data type as stored in information schema. - * @return string|null The column default as stored in information schema. + * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @return string|null The column default as stored in information schema. */ - private function get_column_default( WP_Parser_Node $node, string $data_type ): ?string { + private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { $default_attr = null; foreach ( $node->get_descendant_nodes( 'columnAttribute' ) as $attr ) { if ( $attr->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { @@ -2081,7 +2082,7 @@ private function get_column_default( WP_Parser_Node $node, string $data_type ): // A signed number, such as "-5", has no "literal" child node. return $this->get_value( $signed_literal ); } - return $this->get_literal_default( $literal, $data_type ); + return $this->get_literal_default( $literal, $data_type, $column_name ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses @@ -2096,11 +2097,12 @@ private function get_column_default( WP_Parser_Node $node, string $data_type ): /** * Extract and normalize a literal default value. * - * @param WP_Parser_Node $literal The "literal" AST node. - * @param string $data_type The column data type as stored in information schema. - * @return string|null The default value as stored in information schema. + * @param WP_Parser_Node $literal The "literal" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @return string|null The default value as stored in information schema. */ - private function get_literal_default( WP_Parser_Node $literal, string $data_type ): ?string { + private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name ): ?string { // DEFAULT NULL if ( $literal->has_child_node( 'nullLiteral' ) ) { return null; @@ -2116,7 +2118,15 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type $default = $this->get_value( $literal ); if ( 'bit' === $data_type ) { - return $this->get_bit_default( $default ) ?? $default; + /* + * @TODO: Validate and normalize defaults from their AST and the full + * column definition before storing them in the information schema. + */ + $bit_default = $this->get_bit_default( $default ); + if ( null === $bit_default ) { + throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); + } + return $bit_default; } /* @@ -2143,14 +2153,20 @@ private function get_bit_default( string $default_value ): ?string { $value = strtolower( $default_value ); // Bit literal, e.g. b'101' or 0b101. - if ( str_starts_with( $value, "b'" ) || str_starts_with( $value, '0b' ) ) { - $bits = ltrim( rtrim( substr( $value, 2 ), "'" ), '0' ); + if ( + preg_match( "/\Ab'([01]*)'\z/", $value, $matches ) + || preg_match( '/\A0b([01]+)\z/', $value, $matches ) + ) { + $bits = ltrim( $matches[1], '0' ); return "b'" . ( '' === $bits ? '0' : $bits ) . "'"; } // Hex literal, e.g. x'05' or 0x05. - if ( str_starts_with( $value, "x'" ) || str_starts_with( $value, '0x' ) ) { - return "b'" . decbin( hexdec( rtrim( substr( $value, 2 ), "'" ) ) ) . "'"; + if ( + preg_match( "/\Ax'([0-9a-f]*)'\z/", $value, $matches ) + || preg_match( '/\A0x([0-9a-f]+)\z/', $value, $matches ) + ) { + return "b'" . decbin( hexdec( $matches[1] ) ) . "'"; } // Decimal, e.g. 5, or a numeric string literal such as '0'. diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php index 7b7e62920..c88b50bda 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php @@ -16,6 +16,7 @@ class WP_SQLite_Information_Schema_Exception extends Exception { const TYPE_KEY_COLUMN_NOT_FOUND = 'key-column-not-found'; const TYPE_CONSTRAINT_DOES_NOT_EXIST = 'constraint-does-not-exist'; const TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME = 'multiple-constraints-with-name'; + const TYPE_INVALID_DEFAULT_VALUE = 'invalid-default-value'; /** * The exception type. @@ -151,4 +152,18 @@ public static function multiple_constraints_with_name( string $name ): WP_SQLite array( 'name' => $name ) ); } + + /** + * Create an invalid default value exception. + * + * @param string $column_name The name of the affected column. + * @return self The exception instance. + */ + public static function invalid_default_value( string $column_name ): WP_SQLite_Information_Schema_Exception { + return new self( + self::TYPE_INVALID_DEFAULT_VALUE, + sprintf( "Invalid default value for '%s'.", $column_name ), + array( 'column_name' => $column_name ) + ); + } } diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index e60034f2d..d18865386 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -748,6 +748,68 @@ public function testShowCreateTableWithBitDefaultValues(): void { ); } + /** + * @dataProvider invalidBitDefaultQueries + */ + public function testRejectsInvalidBitDefaultValues( string $query, string $column_name ): void { + $this->assertQuery( 'CREATE TABLE _tmp_invalid_bit_default (id INT, injected BIT(1))' ); + + $exception = null; + try { + $this->query( $query ); + } catch ( WP_MySQL_On_SQLite_Exception $e ) { + $exception = $e; + } + + $driver_message = sprintf( "Invalid default value for '%s'", $column_name ); + $this->assertInstanceOf( WP_MySQL_On_SQLite_Exception::class, $exception ); + $this->assertSame( + 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message, + $exception->getMessage() + ); + $this->assertSame( '42000', $exception->getCode() ); + $this->assertSame( array( '42000', 1067, $driver_message ), $exception->errorInfo ); + } + + public static function invalidBitDefaultQueries(): array { + $payload = "0,\n PRIMARY KEY (`id`)\n); DROP TABLE users; --"; + + return array( + 'CREATE TABLE' => array( + "CREATE TABLE _tmp_created_invalid_bit_default (injected BIT(1) DEFAULT '$payload')", + 'injected', + ), + 'ALTER TABLE ADD' => array( + "ALTER TABLE _tmp_invalid_bit_default ADD COLUMN injected_2 BIT(1) DEFAULT '$payload'", + 'injected_2', + ), + 'ALTER TABLE MODIFY' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '$payload'", + 'injected', + ), + 'empty string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT ''", + 'injected', + ), + 'bit-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT 'b''0''; DROP TABLE users; -- '", + 'injected', + ), + '0b-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '0b0''; DROP TABLE users; -- '", + 'injected', + ), + 'hex-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT 'x''05''; DROP TABLE users; -- '", + 'injected', + ), + '0x-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '0x05; DROP TABLE users; -- '", + 'injected', + ), + ); + } + public function testUpdateWithBitColumnDefaultValue(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults ( From 0315f5df3a57b4d5d50126c541cfc00260958196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 12 Aug 2026 15:11:39 +0200 Subject: [PATCH 2/2] Accept empty BIT column defaults Match MySQL by coercing an empty string default to zero and storing it as a canonical bit literal. --- .../sqlite/class-wp-sqlite-information-schema-builder.php | 5 +++++ .../mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 2847aa8f2..3aa4069c7 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -2152,6 +2152,11 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type private function get_bit_default( string $default_value ): ?string { $value = strtolower( $default_value ); + // An empty string coerces to zero. + if ( '' === $value ) { + return "b'0'"; + } + // Bit literal, e.g. b'101' or 0b101. if ( preg_match( "/\Ab'([01]*)'\z/", $value, $matches ) diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index d18865386..1681fe15f 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -704,6 +704,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults ( id INT DEFAULT 0, + empty_string BIT(1) DEFAULT '', quoted_zero BIT(1) DEFAULT '0', integer_five BIT(4) DEFAULT 5, bit_literal_five BIT(4) DEFAULT b'0101', @@ -718,6 +719,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( 'SHOW CREATE TABLE _tmp_bit_defaults;' ); $results = $this->last_result; $create_table = $results[0]->{'Create Table'}; + $this->assertStringContainsString( "`empty_string` bit(1) DEFAULT b'0'", $create_table ); $this->assertStringContainsString( "`quoted_zero` bit(1) DEFAULT b'0'", $create_table ); $this->assertStringContainsString( "`integer_five` bit(4) DEFAULT b'101'", $create_table ); $this->assertStringContainsString( "`bit_literal_five` bit(4) DEFAULT b'101'", $create_table ); @@ -734,6 +736,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { array( (object) array( 'id' => '1', + 'empty_string' => '0', 'quoted_zero' => '0', 'integer_five' => '5', 'bit_literal_five' => '5', @@ -787,10 +790,6 @@ public static function invalidBitDefaultQueries(): array { "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '$payload'", 'injected', ), - 'empty string' => array( - "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT ''", - 'injected', - ), 'bit-like string' => array( "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT 'b''0''; DROP TABLE users; -- '", 'injected',