clear the intent and carry on as normal * replacement the write landed -> commit it (idempotent); no second backup, no second write * unknown neither (a third party's bytes, or damage we cannot tell from one) -> NEVER written over: the backup is * protected and the row waits for a person (needs_attention) * * The intent is only ever read with these facts. It carries no authority to write: the engine decides, as always. * * Schema: discrete additive columns on the items table (DB 3.0). Until the schema version has been bumped the engine runs * exactly as it did at 2.9: no intent is written and none is read. A version older than 3.0 ignores the columns, and the * backup of a row with a pending intent is held through the same marker and database hold every release since SAF-5 * honours (backup-protection.php). */ if (!defined('ABSPATH')) exit; /** * Is the intent schema in place on THIS site? The version option is bumped only after every column is verified, so * the option is the whole answer; a partly migrated site reads false and runs as 2.9. */ function wpbr_intent_available() { return version_compare((string) get_option('wpbr_db_version', '0'), '3.0', '>='); } /** The row's columns, cleared. Merged into the update that commits or abandons a row. */ function wpbr_intent_cleared() { if (!wpbr_intent_available()) return array(); return array('intent_state' => null, 'intent_attempt' => null, 'intent_base_sha' => null, 'intent_new_sha' => null, 'adapter_id' => null, 'written_sha' => null); } /** * Record the intent. Returns false when it could not be recorded - the caller then does not write. * * @param object $item The row (backup_path and original_sha256 already recorded). * @param WPBR_Storage_Adapter $adapter * @param string $base_sha Hash of the verified backup of the original. * @param string $new_sha Hash of the replacement about to be written. * @param string $backup_path * @param string $live_path The reference being written (recorded in a remote adapter's protection marker). * @return bool */ function wpbr_intent_begin($item, WPBR_Storage_Adapter $adapter, $base_sha, $new_sha, $backup_path, $live_path = '') { if (!wpbr_intent_available()) return true; // running as 2.9: there is nothing to record and nothing to refuse global $wpdb; // A store the engine cannot inspect without a network (any remote adapter) is protected at the SAME moment, so a release // that does not know this table still holds the backup and the path (ADR section 7, downgrade). if ($adapter->capabilities()->get('remote')) { if (!wpbr_protect_backup($backup_path, array( 'sha256' => (string) $base_sha, 'original_sha256' => (string) $base_sha, 'item_id' => (int) $item->id, 'batch_id' => (int) $item->batch_id, 'reason' => 'intent_pending', 'live_path' => (string) $live_path, ))) { return false; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. return false !== $wpdb->update(wpbr_table_items(), array( 'intent_state' => 'pending', 'intent_attempt' => bin2hex(random_bytes(8)), 'intent_base_sha' => (string) $base_sha, 'intent_new_sha' => (string) $new_sha, 'adapter_id' => $adapter->id(), ), array('id' => (int) $item->id)); } /** Abandon the intent: the write is known not to have landed, or it has been committed. */ function wpbr_intent_clear($item_id) { $cleared = wpbr_intent_cleared(); if (!$cleared) return; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned table with no WordPress API; a write, and nothing here is cached, so there is nothing to invalidate. $wpdb->update(wpbr_table_items(), $cleared, array('id' => (int) $item_id)); } /** Does this row carry a pending intent? Reads defensively: a row object without the field has none. */ function wpbr_intent_pending($item) { return wpbr_intent_available() && isset($item->intent_state) && $item->intent_state === 'pending'; } /** * What is at the live path now, against what the intent said was possible. * * @return string original | replacement | unknown. Unreadable or missing is unknown: absence of an answer is never a match. */ function wpbr_intent_classify($item, WPBR_Storage_Adapter $adapter, $live_path) { $seen = $adapter->inspect($live_path, true); // a verification read: from the authority, never a cache if (!$seen->is_ok() || !$seen->fingerprint || $seen->fingerprint->sha256 === '') { return 'unknown'; } $sha = $seen->fingerprint->sha256; if (!empty($item->intent_new_sha) && hash_equals((string) $item->intent_new_sha, $sha)) { return 'replacement'; } if (!empty($item->intent_base_sha) && hash_equals((string) $item->intent_base_sha, $sha)) { return 'original'; } return 'unknown'; } /** * The live file is neither the original nor the replacement: not ours to fix. Protect the backup and wait for a person. * Nothing is written, and in particular nothing is restored over the unknown bytes. */ function wpbr_hold_unknown_intent($item, $live_path, $backup_path) { $backup_sha = $backup_path ? wpbr_file_sha256($backup_path) : ''; $expected = !empty($item->original_sha256) ? (string) $item->original_sha256 : (string) $item->intent_base_sha; $verified = $backup_sha !== '' && ($expected === '' || $backup_sha === $expected); $held = wpbr_mark_needs_attention($item, $live_path, $backup_path, $verified ? 'restore_failed' : 'backup_unverified', $backup_sha); $message = wpbr_needs_attention_message($item, $live_path, $backup_path, $verified, $held['marker'], $held['checked_at']); if ($verified) { return wpbr_run_result('failed', $item->staged_filename, $message, 'needs_attention_restore_failed'); } return wpbr_run_result('failed', $item->staged_filename, $message, 'needs_attention_backup_unverified'); } /** * The columns a commit writes: the intent cleared, and the hash of the bytes that were written, so a later rollback can tell whether * the file is still what this row put there. */ function wpbr_intent_committed($written_sha) { if (!wpbr_intent_available()) return array(); return array_merge(wpbr_intent_cleared(), array('written_sha' => (string) $written_sha)); }