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";