Commit b7e62bd9085 for woocommerce

commit b7e62bd9085f7438d659ea26f3f93fbfe30c9241
Author: MILLER/F <fab@millerf.com>
Date:   Tue Aug 11 10:02:06 2026 +0200

    dev: add PHPCS sniff for unguarded %i identifier placeholder (#66380)

    * dev: add PHPCS sniff for unguarded %i identifier placeholder

    Adds a local PHPCS sniff (WooCommerceInternal.DB.IdentifierPlaceholder) that
    flags the %i SQL identifier placeholder inside wpdb::prepare() calls unless a
    wpdb::has_cap( 'identifier_placeholders' ) guard is present in the same
    function. This prevents reintroducing %i, which some $wpdb drop-ins on
    supported WordPress versions don't implement (silently producing malformed
    queries).

    The sniff lives under tests/Tools/phpcs/ (excluded from being linted as
    product source) and is registered by path in phpcs.xml, scoped to src/ and
    includes/. A fixture and standalone ruleset document and verify its behaviour.

    Depends on the per-area interpolation PRs (#66372, #66374, #66375, #66376,
    #66377, #66378) landing first; until then the sniff flags the existing %i
    call sites. Supersedes #66262.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Apply suggestions from code review

    Co-authored-by: MILLER/F <millerf@automattic.com>

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-identifier-placeholder-sniff b/plugins/woocommerce/changelog/add-identifier-placeholder-sniff
new file mode 100644
index 00000000000..5ed563668d4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-identifier-placeholder-sniff
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add a PHPCS sniff that flags unguarded use of the `%i` SQL identifier placeholder in `wpdb::prepare()` calls, so trusted identifiers are interpolated directly (or `%i` is guarded with `wpdb::has_cap( 'identifier_placeholders' )`).
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 4b3d6a95997..1533bb857e3 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -26,6 +26,8 @@
 	<exclude-pattern>tests/e2e/test-plugins/blocks/</exclude-pattern>
 	<!-- Helper scripts loaded only during e2e tests. -->
 	<exclude-pattern>tests/e2e/bin/</exclude-pattern>
+	<!-- Custom PHPCS sniffs and their fixtures follow PHPCS naming/structure, not the WooCommerce ruleset. -->
+	<exclude-pattern>tests/Tools/phpcs/</exclude-pattern>

 	<!-- Only check PHP files. -->
 	<arg name="extensions" value="php" />
@@ -46,6 +48,13 @@
 	<!-- Rules -->
 	<rule ref="WooCommerce-Core" />

+	<!-- Local sniff: flag `%i` SQL identifier placeholders in wpdb::prepare() that aren't guarded by
+	     wpdb::has_cap( 'identifier_placeholders' ). Trusted identifiers should be interpolated directly. -->
+	<rule ref="tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php">
+		<include-pattern>src/</include-pattern>
+		<include-pattern>includes/</include-pattern>
+    </rule>
+
 	<!-- The cart token selects which customer session loads; it must be read from one place only. -->
 	<rule ref="./bin/phpcs/WooCommerceStoreApi/Sniffs/StoreApi/CartTokenSourceSniff.php">
 		<!-- The canonical accessor, and tests that simulate raw request headers. -->
diff --git a/plugins/woocommerce/tests/Tools/phpcs/README.md b/plugins/woocommerce/tests/Tools/phpcs/README.md
new file mode 100644
index 00000000000..66c0052363f
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/README.md
@@ -0,0 +1,56 @@
+# Local PHPCS sniffs
+
+Custom PHP_CodeSniffer sniffs for WooCommerce Core that are not part of the shared
+[`woocommerce/woocommerce-sniffs`](https://github.com/woocommerce/woocommerce-sniffs)
+package. They live here (rather than in `WooCommerce-Core`) so they can ship and evolve
+with the codebase they guard.
+
+The sniffs are registered by relative path in `plugins/woocommerce/phpcs.xml`, and this
+directory is excluded from being linted as product source (sniff classes must be named
+`XxxSniff.php`, which conflicts with the WooCommerce filename convention).
+
+## `WooCommerceInternal.DB.IdentifierPlaceholder`
+
+Flags the `%i` SQL identifier placeholder inside a `wpdb::prepare()` call when it is **not**
+guarded by `wpdb::has_cap( 'identifier_placeholders' )` in the same function.
+
+WordPress 6.2 added `%i` to `wpdb::prepare()` for quoting table/column names, but a `$wpdb`
+drop-in can run on a supported WordPress version without implementing it (its
+`has_cap( 'identifier_placeholders' )` returns `false`). On such a layer `prepare()` treats
+`%i` as a literal and shifts the remaining positional arguments, silently producing malformed
+queries.
+
+**How to satisfy the sniff:**
+
+- Preferred — interpolate the trusted identifier directly into the query string:
+
+  ```php
+  $table = OrdersTableDataStore::get_orders_table_name();
+  // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+  $wpdb->prepare( "SELECT id FROM {$table} WHERE customer_id = %d", $customer_id );
+  ```
+
+- When `%i` is genuinely required, guard it and provide a fallback:
+
+  ```php
+  if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
+      $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id );
+  }
+  ```
+
+The identifier MUST be a trusted, developer-provided value (a table or column name), never raw
+user input.
+
+### Verifying the sniff
+
+The fixture `WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc` documents the
+flagged and allowed cases. Run the sniff over it directly:
+
+```sh
+cd plugins/woocommerce
+bin/composer/phpcs/vendor/bin/phpcs -s \
+  --standard=tests/Tools/phpcs/WooCommerceInternal/ruleset.xml \
+  tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
+```
+
+Only the two unguarded `%i` cases (cases 1 and 2) should be reported.
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php
new file mode 100644
index 00000000000..6f4ff038d73
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Sniffs/DB/IdentifierPlaceholderSniff.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * IdentifierPlaceholderSniff.
+ *
+ * @package WooCommerce\Tests\Tools\PHPCS
+ */
+
+declare( strict_types=1 );
+
+namespace WooCommerceInternal\Sniffs\DB;
+
+use PHP_CodeSniffer\Files\File;
+use PHP_CodeSniffer\Sniffs\Sniff;
+use PHP_CodeSniffer\Util\Tokens;
+
+/**
+ * Flags use of the `%i` SQL identifier placeholder inside a `wpdb::prepare()` call
+ * that is not guarded by `wpdb::has_cap( 'identifier_placeholders' )`.
+ *
+ * WordPress 6.2 added `%i` to `wpdb::prepare()` for quoting table/column names, but a
+ * `$wpdb` drop-in can run on a supported WordPress version without implementing it (its
+ * `has_cap( 'identifier_placeholders' )` returns `false`). On such a layer `prepare()`
+ * treats `%i` as a literal and shifts the remaining positional arguments, silently
+ * producing malformed queries.
+ *
+ * Trusted identifiers (table/column names derived from `$wpdb->prefix`, `$wpdb->posts`,
+ * or a data store's `get_*_table_name()`) should be interpolated directly into the query
+ * string instead. When `%i` is genuinely required, guard it with a
+ * `wpdb::has_cap( 'identifier_placeholders' )` check in the same function so a fallback
+ * path can run on layers that lack `%i`.
+ */
+class IdentifierPlaceholderSniff implements Sniff {
+
+	/**
+	 * Capability string that unlocks the `%i` placeholder.
+	 *
+	 * @var string
+	 */
+	private const GUARD_CAPABILITY = 'identifier_placeholders';
+
+	/**
+	 * Registers the tokens this sniff wants to listen for.
+	 *
+	 * @return array<int|string>
+	 */
+	public function register(): array {
+		return array(
+			T_CONSTANT_ENCAPSED_STRING,
+			T_DOUBLE_QUOTED_STRING,
+		);
+	}
+
+	/**
+	 * Processes a string token, flagging an unguarded `%i` placeholder in a prepare() call.
+	 *
+	 * @param File $phpcs_file The file being scanned.
+	 * @param int  $stack_ptr  The position of the current token in the stack.
+	 * @return void
+	 */
+	public function process( File $phpcs_file, $stack_ptr ): void {
+		$tokens  = $phpcs_file->getTokens();
+		$content = $tokens[ $stack_ptr ]['content'];
+
+		// Flag a `%i` identifier placeholder appearing in the string.
+		// - `(?<![:%])` excludes STR_TO_DATE minute specifiers (`%H:%i:%s`) and escaped `%%i`.
+		// - `(?![a-zA-Z0-9_])` excludes longer tokens such as `%input`.
+		if ( ! preg_match( '/(?<![:%])%i(?![a-zA-Z0-9_])/', $content ) ) {
+			return;
+		}
+
+		if ( ! $this->is_within_prepare_call( $phpcs_file, $stack_ptr ) ) {
+			return;
+		}
+
+		if ( $this->has_identifier_placeholder_guard( $phpcs_file, $stack_ptr ) ) {
+			return;
+		}
+
+		$phpcs_file->addError(
+			'The %%i identifier placeholder is not implemented by every $wpdb drop-in on supported WordPress versions and can silently produce malformed queries. Interpolate a trusted identifier directly into the query string, or guard the %%i usage with wpdb::has_cap( \'identifier_placeholders\' ).',
+			$stack_ptr,
+			'Unguarded'
+		);
+	}
+
+	/**
+	 * Determines whether the given string token is an argument to a `prepare()` call.
+	 *
+	 * @param File $phpcs_file The file being scanned.
+	 * @param int  $stack_ptr  The position of the string token.
+	 * @return bool
+	 */
+	private function is_within_prepare_call( File $phpcs_file, int $stack_ptr ): bool {
+		$tokens = $phpcs_file->getTokens();
+
+		if ( empty( $tokens[ $stack_ptr ]['nested_parenthesis'] ) ) {
+			return false;
+		}
+
+		foreach ( array_keys( $tokens[ $stack_ptr ]['nested_parenthesis'] ) as $open_paren ) {
+			$before = $phpcs_file->findPrevious( Tokens::$emptyTokens, $open_paren - 1, null, true );
+
+			if ( false !== $before
+				&& T_STRING === $tokens[ $before ]['code']
+				&& 'prepare' === strtolower( $tokens[ $before ]['content'] )
+			) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Determines whether the enclosing function guards `%i` usage with a
+	 * `has_cap( 'identifier_placeholders' )` check.
+	 *
+	 * @param File $phpcs_file The file being scanned.
+	 * @param int  $stack_ptr  The position of the string token.
+	 * @return bool
+	 */
+	private function has_identifier_placeholder_guard( File $phpcs_file, int $stack_ptr ): bool {
+		$tokens = $phpcs_file->getTokens();
+
+		$start = 0;
+		$end   = $phpcs_file->numTokens - 1;
+
+		// Narrow the search to the innermost enclosing function/closure when there is one.
+		if ( ! empty( $tokens[ $stack_ptr ]['conditions'] ) ) {
+			foreach ( array_reverse( $tokens[ $stack_ptr ]['conditions'], true ) as $ptr => $code ) {
+				if ( ( T_FUNCTION === $code || T_CLOSURE === $code )
+					&& isset( $tokens[ $ptr ]['scope_opener'], $tokens[ $ptr ]['scope_closer'] )
+				) {
+					$start = $tokens[ $ptr ]['scope_opener'];
+					$end   = $tokens[ $ptr ]['scope_closer'];
+					break;
+				}
+			}
+		}
+
+		for ( $i = $start; $i <= $end; $i++ ) {
+			if ( T_STRING !== $tokens[ $i ]['code'] || 'has_cap' !== strtolower( $tokens[ $i ]['content'] ) ) {
+				continue;
+			}
+
+			$open_paren = $phpcs_file->findNext( Tokens::$emptyTokens, $i + 1, null, true );
+			if ( false === $open_paren
+				|| T_OPEN_PARENTHESIS !== $tokens[ $open_paren ]['code']
+				|| empty( $tokens[ $open_paren ]['parenthesis_closer'] )
+			) {
+				continue;
+			}
+
+			$close_paren = $tokens[ $open_paren ]['parenthesis_closer'];
+			for ( $j = $open_paren + 1; $j < $close_paren; $j++ ) {
+				if ( ( T_CONSTANT_ENCAPSED_STRING === $tokens[ $j ]['code'] || T_DOUBLE_QUOTED_STRING === $tokens[ $j ]['code'] )
+					&& false !== strpos( $tokens[ $j ]['content'], self::GUARD_CAPABILITY )
+				) {
+					return true;
+				}
+			}
+		}
+
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
new file mode 100644
index 00000000000..9be67b75da8
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/Tests/DB/IdentifierPlaceholderUnitTest.inc
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Fixture for IdentifierPlaceholderSniff.
+ *
+ * Each numbered case documents whether the sniff should flag it.
+ *
+ * @package WooCommerce\Tests\Tools\PHPCS
+ */
+
+// Case 1: unguarded %i in prepare() — FLAGGED.
+function case_unguarded_single_line() {
+	global $wpdb;
+	return $wpdb->get_var( $wpdb->prepare( 'SELECT session_value FROM %i WHERE session_key = %s', $table, $key ) );
+}
+
+// Case 2: unguarded %i in a multi-line prepare() — FLAGGED.
+function case_unguarded_multi_line() {
+	global $wpdb;
+	return $wpdb->get_results(
+		$wpdb->prepare(
+			"SELECT id
+			FROM %i
+			WHERE type = %s",
+			$table,
+			$type
+		)
+	);
+}
+
+// Case 3: %i guarded by has_cap( 'identifier_placeholders' ) in the same function — NOT flagged.
+function case_guarded() {
+	global $wpdb;
+	if ( $wpdb->has_cap( 'identifier_placeholders' ) ) {
+		return $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id );
+	}
+	return $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+}
+
+// Case 4: direct interpolation, no %i — NOT flagged.
+function case_interpolated() {
+	global $wpdb;
+	return $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id );
+}
+
+// Case 5: %%i escaped literal and %H:%i:%s time specifier — NOT flagged.
+function case_time_and_escape() {
+	global $wpdb;
+	$a = $wpdb->prepare( "SELECT DATE_FORMAT( created, '%%Y-%%m-%%d %%H:%%i:%%s' ) FROM {$table} WHERE id = %d", $id );
+	$b = $wpdb->prepare( 'SELECT a %%i b FROM ' . $table, $id );
+	return array( $a, $b );
+}
+
+// Case 6: %i in a non-prepare() query string — NOT flagged (only prepare() is targeted).
+function case_not_prepare() {
+	global $wpdb;
+	return $wpdb->get_var( 'SELECT progress %i FROM somewhere' );
+}
diff --git a/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml
new file mode 100644
index 00000000000..5af186b2120
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpcs/WooCommerceInternal/ruleset.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<ruleset name="WooCommerceInternal">
+	<description>Local WooCommerce Core PHPCS sniffs. Used standalone to verify the sniffs against their fixtures; the sniffs are wired into the main ruleset from plugins/woocommerce/phpcs.xml.</description>
+
+	<rule ref="./Sniffs/DB/IdentifierPlaceholderSniff.php" />
+</ruleset>