185 lines
11 KiB
PHP
185 lines
11 KiB
PHP
<?php
|
||
// Run with: php tests/repair.php (PHP with PDO SQLite and iconv).
|
||
define('ABSPATH', __DIR__);
|
||
define('ARRAY_A', 'ARRAY_A');
|
||
define('DB_NAME', 'repair_test');
|
||
function add_action(...$args) {}
|
||
function get_current_user_id() { return 7; }
|
||
function wp_json_encode($value) { return json_encode($value); }
|
||
function wp_salt($scheme) { return 'local-test-salt'; }
|
||
function wp_cache_flush() { $GLOBALS['flushes']++; return true; }
|
||
require dirname(__DIR__) . '/wp-mojibake-repair.php';
|
||
|
||
function call_repair($method, ...$args) {
|
||
static $methods = array();
|
||
if (!isset($methods[$method])) {
|
||
$methods[$method] = new ReflectionMethod('WP_Mojibake_Repair_Temporary', $method);
|
||
if (PHP_VERSION_ID < 80100) {
|
||
$methods[$method]->setAccessible(true);
|
||
}
|
||
}
|
||
return $methods[$method]->invokeArgs(null, $args);
|
||
}
|
||
$checks = 0;
|
||
function same($expected, $actual, $label) {
|
||
$GLOBALS['checks']++;
|
||
if ($expected !== $actual) {
|
||
throw new RuntimeException($label . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
|
||
}
|
||
}
|
||
function refused($callback, $label) {
|
||
try { $callback(); } catch (RuntimeException $e) { $GLOBALS['checks']++; return; }
|
||
throw new RuntimeException($label . ': expected refusal');
|
||
}
|
||
function fix($value) { return call_repair('repair_value', $value); }
|
||
function broken($value) { return iconv('ISO-8859-1', 'UTF-8', $value); }
|
||
|
||
$czech = 'Příliš žluťoučký kůň úpěl ďábelské ódy. ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ';
|
||
same($czech, fix(broken($czech)), 'Latin-1 mojibake, all Czech letters');
|
||
// These letters exercise printable Windows-1252 mappings including ™, ¾ and œ.
|
||
$windows = 'ěňřšťůžČĎĚŇŘŠŤŮŽ';
|
||
same($windows, fix(iconv('Windows-1252', 'UTF-8', $windows)), 'Windows-1252 mojibake');
|
||
$punctuation = array(
|
||
'–' => '–', '—' => '—', '‘' => '‘', '’' => '’', '‚' => '‚',
|
||
'“' => '“', "â€\u{009D}" => '”', '„' => '„', '…' => '…', '•' => '•',
|
||
'«' => '«', '»' => '»', '‹' => '‹', '›' => '›', '€' => '€',
|
||
'â„¢' => '™', '©' => '©', '®' => '®', '°' => '°', "Â\u{00A0}" => "\u{00A0}",
|
||
);
|
||
foreach ($punctuation as $damaged => $clean) {
|
||
same($clean, fix($damaged), 'Windows-1252 punctuation: ' . $clean);
|
||
same($clean, fix(broken($clean)), 'Latin-1 punctuation: ' . $clean);
|
||
}
|
||
$clean_punctuation = implode('', $punctuation);
|
||
same($clean_punctuation, fix($clean_punctuation), 'Correct punctuation and nonbreaking space unchanged');
|
||
$damaged_punctuation = implode('', array_keys($punctuation));
|
||
same($clean_punctuation, fix(fix($damaged_punctuation)), 'Repeated punctuation repair is stable');
|
||
$punctuation_settings = array('font_family' => 'Open Sans', 'label' => $damaged_punctuation);
|
||
$punctuation_expected = array('font_family' => 'Open Sans', 'label' => $clean_punctuation);
|
||
same(serialize($punctuation_expected), fix(serialize($punctuation_settings)), 'Punctuation repair preserves serialized typography settings');
|
||
$punctuation_css = 'body{font-family:"Open Sans"}/* – ’ … */';
|
||
same('body{font-family:"Open Sans"}/* – ’ … */', fix($punctuation_css), 'Punctuation in CSS preserves styling');
|
||
$punctuation_json = '{"font":"Open Sans","url":"https:\/\/example.test","label":"– ’ …","escaped":"\\u2013"}';
|
||
same(strtr($punctuation_json, $punctuation), fix($punctuation_json), 'Punctuation in JSON preserves escaping');
|
||
$correct = $czech . ' 日本語 😀 € “quoted” — ß ö ñ';
|
||
same($correct, fix($correct), 'Correct multilingual Unicode is byte-for-byte unchanged');
|
||
same($correct . ' Příliš', fix($correct . ' ' . broken('Příliš')), 'Mixed clean and damaged text');
|
||
same($czech, fix(fix(broken($czech))), 'Second pass preserves repaired text');
|
||
$css = 'body{font-family:"Open Sans",sans-serif;font-weight:600;background:url("/a\\b.woff2")}/* ';
|
||
same($css . 'Příliš */', fix($css . broken('Příliš') . ' */'), 'CSS bytes stay intact');
|
||
$json = '{"font_family":"Open Sans","url":"https:\/\/example.test\/font.woff2","unicode":"\\u010d","text":"' . broken('Příliš') . '"}';
|
||
same(str_replace(broken('Příliš'), 'Příliš', $json), fix($json), 'JSON escaping stays intact');
|
||
$settings = array('font_family' => 'Open Sans', 'font_weight' => 600, 'label' => broken('Příliš'), 'nested' => array('css' => $css . '*/', 'json' => $json), 'enabled' => true, 'empty' => null, 'scale' => 1.25);
|
||
$expected = $settings;
|
||
$expected['label'] = 'Příliš';
|
||
$expected['nested']['json'] = fix($json);
|
||
same(false, @unserialize(call_repair('repair_text', serialize($settings))), 'Replacing serialized text without fixing lengths breaks settings');
|
||
$repaired = fix(serialize($settings));
|
||
same($expected, unserialize($repaired), 'Serialized font settings remain readable with correct values');
|
||
same(serialize($expected), $repaired, 'Serialized byte lengths are recalculated');
|
||
same($repaired, fix($repaired), 'Serialized settings survive repeat repair');
|
||
$nested = serialize(array('inner' => serialize(array('label' => broken('kůň')))));
|
||
same(array('label' => 'kůň'), unserialize(unserialize(fix($nested))['inner']), 'Nested serialization lengths');
|
||
same(serialize(array(broken('č') => 'č')), fix(serialize(array(broken('č') => broken('č')))), 'Serialized keys stay unchanged');
|
||
$safe = 'a:2:{s:1:"x";d:1.2500;s:1:"y";' . serialize(broken('č')) . '}';
|
||
same(str_replace(serialize(broken('č')), serialize('č'), $safe), fix($safe), 'Scalar formatting stays intact');
|
||
$malformed = 'a:1:{s:5:"label";s:999:"' . broken('Příliš') . '";}';
|
||
refused(function () use ($malformed) { fix($malformed); }, 'Already broken serialization');
|
||
refused(function () { fix(serialize(broken('č')) . 'trailing'); }, 'Trailing serialized data');
|
||
refused(function () { fix(' ' . serialize(broken('č'))); }, 'Noncanonical serialized wrapper');
|
||
refused(function () { fix(broken('č') . "\xff"); }, 'Invalid UTF-8 candidate');
|
||
class RepairTestObject {
|
||
public $label;
|
||
public function __wakeup() { throw new LogicException('Must never instantiate objects'); }
|
||
}
|
||
$object = new RepairTestObject();
|
||
$object->label = broken('č');
|
||
refused(function () use ($object) { fix(serialize($object)); }, 'Serialized objects skipped without hooks');
|
||
refused(function () use ($object) { fix(serialize(array('label' => broken('č'), 'object' => $object))); }, 'Whole row skipped for nested objects');
|
||
$reference = broken('č');
|
||
$references = array(&$reference, &$reference);
|
||
refused(function () use ($references) { fix(serialize($references)); }, 'References skipped');
|
||
$deep = broken('č');
|
||
for ($i = 0; $i < 70; $i++) { $deep = array($deep); }
|
||
refused(function () use ($deep) { fix(serialize($deep)); }, 'Depth bounded');
|
||
|
||
// Exercise the scan, exact-value approvals and transaction flow with SQLite.
|
||
// This adapter is not a substitute for verification on a WordPress/MySQL site.
|
||
class RepairTestDB {
|
||
public $prefix = 'wp_';
|
||
public $last_error = '';
|
||
public $db;
|
||
public $fail_update = false;
|
||
public $conflict = false;
|
||
public function __construct() {
|
||
$this->db = new PDO('sqlite::memory:');
|
||
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$this->db->exec('CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_value TEXT)');
|
||
}
|
||
public function prepare($sql, ...$args) {
|
||
return preg_replace_callback('/%[sd]/', function ($match) use (&$args) {
|
||
$value = array_shift($args);
|
||
return $match[0] === '%d' ? (string) (int) $value : $this->db->quote($value);
|
||
}, $sql);
|
||
}
|
||
public function get_results($sql, $format) { return $this->db->query($sql)->fetchAll(PDO::FETCH_ASSOC); }
|
||
public function query($sql) {
|
||
if ($sql === 'START TRANSACTION') { return $this->db->beginTransaction() ? 0 : false; }
|
||
if ($sql === 'COMMIT') { return $this->db->commit() ? 0 : false; }
|
||
if ($sql === 'ROLLBACK') { return $this->db->rollBack() ? 0 : false; }
|
||
if ($this->fail_update) { $this->last_error = 'simulated write failure'; return false; }
|
||
if ($this->conflict) { return 0; }
|
||
// SQLite text equality already compares bytes with its default collation.
|
||
return $this->db->exec(str_replace('BINARY ', '', $sql));
|
||
}
|
||
public function put($id, $text) {
|
||
$this->db->prepare('INSERT OR REPLACE INTO wp_options VALUES (?, ?)')->execute(array($id, $text));
|
||
}
|
||
public function all() { return $this->db->query('SELECT * FROM wp_options ORDER BY option_id')->fetchAll(PDO::FETCH_ASSOC); }
|
||
}
|
||
$wpdb = new RepairTestDB();
|
||
$GLOBALS['flushes'] = 0;
|
||
$plan = array('wp_options' => array('key' => 'option_id', 'columns' => array('option_value')));
|
||
for ($i = 0; $i < 405; $i++) { $wpdb->put($i, serialize($settings)); }
|
||
$wpdb->put(500, $malformed);
|
||
$wpdb->put(501, $correct);
|
||
$wpdb->put(502, null);
|
||
$before = $wpdb->all();
|
||
$preview = call_repair('preview', $plan);
|
||
same($before, $wpdb->all(), 'Preview is read-only');
|
||
same(405, $preview['total'], 'Batches include all rows including ID zero');
|
||
same(1, $preview['skipped'], 'Malformed row reported separately');
|
||
same(array('500'), $preview['columns'][0]['skipped_ids'], 'Skipped ID shown');
|
||
same(5, count($preview['columns'][0]['samples']), 'Sample limit');
|
||
$token = call_repair('make_approval', $preview);
|
||
$approval = call_repair('verify_approval', $token, $plan);
|
||
refused(function () use ($token, $plan) { call_repair('verify_approval', 'x' . $token, $plan); }, 'Tampered approval');
|
||
$old = array('user' => 7, 'database' => DB_NAME, 'prefix' => 'wp_', 'expires' => time() + 600, 'counts' => $preview['counts']);
|
||
$encoded = rtrim(strtr(base64_encode(json_encode($old)), '+/', '-_'), '=');
|
||
$old_token = $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth'));
|
||
refused(function () use ($old_token, $plan) { call_repair('verify_approval', $old_token, $plan); }, 'Old blanket-conversion approval rejected');
|
||
$wpdb->put(0, broken('Jiný text'));
|
||
$changed = $wpdb->all();
|
||
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Same count but changed values rejected');
|
||
same($changed, $wpdb->all(), 'Every write rolled back for stale preview');
|
||
same(0, $GLOBALS['flushes'], 'No cache flush after rollback');
|
||
$wpdb->put(0, serialize($settings));
|
||
$wpdb->conflict = true;
|
||
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Concurrent write conflict');
|
||
same($before, $wpdb->all(), 'Conflict leaves database unchanged');
|
||
$wpdb->conflict = false;
|
||
$wpdb->fail_update = true;
|
||
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Database error rolls back');
|
||
same($before, $wpdb->all(), 'Failed update leaves database unchanged');
|
||
$wpdb->fail_update = false;
|
||
$wpdb->last_error = '';
|
||
$result = call_repair('execute', $plan, $approval);
|
||
same(405, $result['total'], 'Approved updates committed');
|
||
same(1, $GLOBALS['flushes'], 'Object cache cleared after commit');
|
||
$after = $wpdb->all();
|
||
same($expected, unserialize($after[0]['option_value']), 'Database font settings remain readable');
|
||
same($malformed, $after[405]['option_value'], 'Malformed row untouched');
|
||
same($correct, $after[406]['option_value'], 'Clean row untouched');
|
||
same(null, $after[407]['option_value'], 'NULL untouched');
|
||
same(0, call_repair('preview', $plan)['total'], 'Second preview has no repair candidates');
|
||
echo 'Passed ' . $checks . " checks.\n";
|