Files
heretek-control-core/includes/migrate/class-db-importer.php
T
John SmithandClaude 1bc1de23db fix(sonar): harden connector transfer path + reduce engine complexity
Addresses the first SonarCloud analysis on PR #1 (128 new-code findings).
Genuine issues are fixed in code; convention/style rules (camelCase naming
php:S100/S101/S116, too-many-returns php:S1142) and the standalone test
harnesses are handled via the SonarCloud profile + sonar-project.properties
because snake_case + WP_Error guards are the WordPress conventions here.

Security:
- connector: transfer ids are now whitelisted ([A-Za-z0-9_-]{8,64}) and
  rejected before any path use; the residual S2083 path-build findings (the
  resume-keyed store must use the id) carry NOSONAR + rationale after the HMAC
  gate + strict validation.

Complexity (php:S3776) - extracted helpers so no function sits far over the
raised threshold:
- connector: search_replace -> data_tables/text_columns/rewrite_rows;
  restore_archive -> restore_db; import -> run_dump_statement.
- migrate-abilities: execute_migrate -> push_archive_packets/packet_error/
  push_finalize/poll_job.
- db-importer: run_statement helper (per-statement execution shared with the
  trailing no-';' fragment).
- restore-engine: restore() -> restore_database(); append_log() dropped its
  unused $filename parameter.
- char-state-machine extractors (extract_statement x2) carry NOSONAR + rationale.

Other:
- packager: .emcp extension -> const EXT; add_files split into file_iterator/
  dir_is_skipped/zip_relative_name.
- db-exporter: removed unused $col_ph.
- migrate module: single-arg nonces (WP default _wpnonce, matching the view),
  page-URL helper, dropped unused $back params.
- page-migrate view: single-arg wp_nonce_field().
- restore-engine + serialized-search-replace: tabs -> spaces.
- sonar-project.properties: exclude tests/** + vendor/** (test harnesses mirror
  WP core function names by design).

Tests: tests/test-migrate-packager.php 8 suites still green; php -l clean.

Co-Authored-By: Claude <[email protected]>
2026-09-03 01:38:17 -04:00

199 lines
5.9 KiB
PHP

<?php
/**
* Database Importer for .emcp SQL dumps.
*
* Streams the dump line-by-line and splits statements with a quote-aware
* scanner, so a semicolon inside a quoted value (or a CREATE TABLE column
* comment) can never truncate a statement. Session transaction-control
* directives (SET AUTOCOMMIT / START TRANSACTION / COMMIT / BEGIN / ROLLBACK)
* are deliberately skipped — running them on the shared $wpdb connection would
* otherwise leave autocommit off and silently roll every later write back at
* request end (a live-caught bug in the original Backup/Restore port).
*
* @package EMCP_Tools
* @since 3.12.0
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
class EMCP_Tools_DB_Importer {
/** Directives that must never run on the live connection. */
const SKIP_PREFIXES = array(
'SET AUTOCOMMIT',
'START TRANSACTION',
'BEGIN',
'COMMIT',
'ROLLBACK',
);
/**
* Import a .sql dump into the current database.
*
* @param string $filepath Path to the dump.
* @return array|false { statements, executed, skipped, errors, error_details } or false when the file is missing.
*/
public static function import_from_file( string $filepath ) {
if ( ! is_file( $filepath ) ) {
return false;
}
global $wpdb;
$stats = array(
'statements' => 0,
'executed' => 0,
'skipped' => 0,
'errors' => 0,
'error_details' => array(),
);
$handle = fopen( $filepath, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( false === $handle ) {
return false;
}
$buffer = '';
while ( false !== ( $line = fgets( $handle ) ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
$buffer .= $line;
while ( null !== ( $stmt = self::extract_statement( $buffer ) ) ) {
$stats['statements']++;
$trimmed = self::strip_leading_comments( $stmt );
if ( self::is_skip_directive( $trimmed ) ) {
$stats['skipped']++;
continue;
}
self::run_statement( $trimmed, $stats );
}
}
// Trailing statement with no final ';' — execute it if anything remains.
$trimmed = self::strip_leading_comments( $buffer );
if ( '' !== $trimmed && ! self::is_skip_directive( $trimmed ) ) {
$stats['statements']++;
self::run_statement( $trimmed, $stats );
}
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
return $stats;
}
/**
* Run one dump statement through the live connection, updating stats.
*
* @param string $sql Trimmed statement (directives already filtered).
* @param array $stats In/out counters.
*/
private static function run_statement( string $sql, array &$stats ): void {
global $wpdb;
$result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB -- validated dump statement; admin-authorized restore.
if ( false === $result ) {
$stats['errors']++;
if ( count( $stats['error_details'] ) < 20 ) {
$stats['error_details'][] = array(
'error' => $wpdb->last_error,
'stmt' => self::truncate( $sql, 200 ),
);
}
} else {
$stats['executed']++;
}
}
/**
* Drop full-line `--` comments that lead a statement so a directive after a
* dump banner (which begins with `--`) is still recognised as such.
*
* @param string $stmt Statement (may include leading comment lines).
* @return string Comment-free statement.
*/
private static function strip_leading_comments( string $stmt ): string {
$out = preg_replace( '/^[ \t]*--[^\r\n]*(?:\r?\n|$)/m', '', $stmt );
return null === $out ? $stmt : trim( $out );
}
/**
* Pull one complete statement (ending in a top-level ';') off the buffer.
*
* @param string $buffer In/out accumulator (reference).
* @return string|null The statement including its trailing ';', or null when no complete statement exists yet.
*/
private static function extract_statement( string &$buffer ) { // NOSONAR -- a per-character SQL quote/comment state machine has no meaningful decomposition below its branch table.
$len = strlen( $buffer );
$i = 0;
$sq = false; // Single-quoted string.
$dq = false; // Double-quoted string.
$bt = false; // Backtick-quoted identifier.
$lc = false; // Line comment (-- to EOL).
$bc = false; // Block comment (/* ... */).
while ( $i < $len ) {
$c = $buffer[ $i ];
if ( $lc ) {
if ( "\n" === $c ) {
$lc = false;
}
} elseif ( $bc ) {
if ( '*' === $c && isset( $buffer[ $i + 1 ] ) && '/' === $buffer[ $i + 1 ] ) {
$bc = false;
$i++;
}
} elseif ( $sq || $dq || $bt ) {
if ( '\\' === $c ) {
$i++; // Skip escaped character.
} elseif ( $sq && "'" === $c ) {
$sq = false;
} elseif ( $dq && '"' === $c ) {
$dq = false;
} elseif ( $bt && '`' === $c ) {
$bt = false;
}
} else {
if ( "'" === $c ) {
$sq = true;
} elseif ( '"' === $c ) {
$dq = true;
} elseif ( '`' === $c ) {
$bt = true;
} elseif ( '-' === $c && isset( $buffer[ $i + 1 ] ) && '-' === $buffer[ $i + 1 ] ) {
$lc = true;
$i++;
} elseif ( '/' === $c && isset( $buffer[ $i + 1 ] ) && '*' === $buffer[ $i + 1 ] ) {
$bc = true;
$i++;
} elseif ( ';' === $c ) {
$stmt = substr( $buffer, 0, $i + 1 );
$buffer = substr( $buffer, $i + 1 );
return $stmt;
}
}
$i++;
}
return null;
}
/**
* Whether a trimmed statement is a session transaction-control directive.
*
* @param string $stmt Trimmed statement.
* @return bool
*/
private static function is_skip_directive( string $stmt ): bool {
$upper = strtoupper( $stmt );
foreach ( self::SKIP_PREFIXES as $prefix ) {
if ( 0 === strpos( $upper, $prefix ) ) {
return true;
}
}
return false;
}
/**
* Truncate a statement for an error report.
*
* @param string $stmt Statement.
* @param int $max Max chars.
* @return string
*/
private static function truncate( string $stmt, int $max = 200 ): string {
if ( strlen( $stmt ) <= $max ) {
return $stmt;
}
return substr( $stmt, 0, $max ) . '…';
}
}