look (fresh) -> backup to the private LOCAL backups folder -> write-ahead intent -> guarded write over the base * just backed up -> verify with a fresh read -> commit (intent cleared, written hash recorded, protection released) -> purge * * A write is attempted only when the adapter declares an exclusion mode the site can stand behind: a provider conditional write, * or an explicit exclusive-writer attestation in wp-config. An answer the adapter cannot give (a lost reply) is resolved by * looking, never by assuming. Bytes that are neither the original nor the replacement are never written over. */ if (!defined('ABSPATH')) exit; /** * The privacy gate in front of every backup: measured now, once per request, not read from a day-old verdict; and decided here in * the engine, so no screen, job or click can skip it. Local and host-managed rows go through the same function. * * @return array|null A run result that refuses the row, or null when the backups folder may be written. */ function wpbr_backup_gate_refusal($item, $live_path) { $gate = wpbr_privacy_gate('backups', array('max_age' => 0, 'files' => array($live_path))); if (!$gate['allow']) { if ($gate['contradicted']) { wpbr_log((int) $item->batch_id, (int) $item->id, $item->staged_filename, 'privacy_gate', 'failed', __('A deployer attestation says this folder is private, but measurement shows it is readable from the web. The measurement wins.', 'wpbr-bulk-replace')); } if ($gate['code'] === 'blocked_exposed') { return wpbr_run_result('failed', $item->staged_filename, $gate['message'], 'blocked_exposed'); } if ($gate['code'] === 'blocked_unmeasured') { return wpbr_run_result('failed', $item->staged_filename, $gate['message'], 'blocked_unmeasured'); } if ($gate['code'] === 'blocked_unsupported_storage') { return wpbr_run_result('failed', $item->staged_filename, $gate['message'], 'blocked_unsupported_storage'); } return wpbr_run_result('failed', $item->staged_filename, $gate['message'], 'blocked_unproven'); } if ($gate['attested']) { wpbr_log((int) $item->batch_id, (int) $item->id, $item->staged_filename, 'privacy_gate', 'success', sprintf( /* translators: %s is the reason the deployer gave. */ __('Private by deployer attestation: %s', 'wpbr-bulk-replace'), $gate['attestation_reason'] )); } return null; } /** Is $ref a stream-wrapper URI (vip://..., s3://...) rather than a path on this server? */ function wpbr_ref_is_stream_uri($ref) { return (bool) preg_match('#^[a-z][a-z0-9+.\-]*://#i', (string) $ref); } /** * The stream-style bases a reference may live under: the uploads location when the host stores uploads on a stream wrapper, plus * what a storage module that owns remote originals declares (`wpbr_remote_ref_bases`, each a `scheme://...` prefix). * * @return string[] */ function wpbr_remote_ref_bases() { $bases = array((string) wp_upload_dir()['basedir']); foreach ((array) apply_filters('wpbr_remote_ref_bases', array()) as $extra) { if (is_string($extra) && wpbr_ref_is_stream_uri($extra)) { $bases[] = $extra; } } return $bases; } /** May the engine treat $ref as one of this site's files? Judged on the text, against every declared base. */ function wpbr_remote_ref_allowed($ref) { foreach (wpbr_remote_ref_bases() as $base) { if (wpbr_remote_ref_within($base, $ref)) { return true; } } return false; } /** * Is $ref inside $base, judged on the text alone? A stream URI has no realpath(), so containment is decided by normalising the * path segments and refusing anything that climbs, and then comparing prefixes with a separator. */ function wpbr_remote_ref_within($base, $ref) { $base = (string) $base; $ref = (string) $ref; if ($ref === '' || $base === '' || strpos($ref, "\0") !== false || strpos($ref, '\\') !== false) { return false; } $scheme = strtolower((string) strstr($base, '://', true)); if ($scheme === '' || strtolower((string) strstr($ref, '://', true)) !== $scheme) { return false; } $tail = substr($ref, strpos($ref, '://') + 3); foreach (explode('/', $tail) as $segment) { if ($segment === '..' || $segment === '.') { return false; } } $base = rtrim($base, '/') . '/'; return strpos($ref, $base) === 0 && strlen($ref) > strlen($base); } /** * The exclusive-writer attestations the deployer has made in wp-config: adapter id => array(reason, by, date). Never a setting a * screen or a request can change. */ function wpbr_storage_attestations() { if (function_exists('wpbr_test_seams_active') && wpbr_test_seams_active() && isset($GLOBALS['wpbr_test_attestations'])) { return (array) $GLOBALS['wpbr_test_attestations']; } return defined('WPBR_STORAGE_EXCLUSIVE_WRITER') && is_array(WPBR_STORAGE_EXCLUSIVE_WRITER) ? WPBR_STORAGE_EXCLUSIVE_WRITER : array(); } /** * May the engine overwrite through this adapter, and on what footing? * * @return array{ok:bool,mode:string,reason:string} reason: '' | no_exclusion | not_attested */ function wpbr_storage_exclusion_decision(WPBR_Storage_Adapter $adapter) { $caps = $adapter->capabilities(); $mode = (string) $caps->get('exclusion'); if (!$caps->can_overwrite()) { return array('ok' => false, 'mode' => $mode, 'reason' => 'no_exclusion'); } if ($mode === WPBR_Storage_Capabilities::EXCLUSION_CONTRACT) { $all = wpbr_storage_attestations(); $own = isset($all[$adapter->id()]) && is_array($all[$adapter->id()]) ? $all[$adapter->id()] : array(); if (empty($own['reason']) || !is_string($own['reason']) || trim($own['reason']) === '') { return array('ok' => false, 'mode' => $mode, 'reason' => 'not_attested'); } } return array('ok' => true, 'mode' => $mode, 'reason' => ''); } /** What a person is told when a row is on storage no module here can write to. */ function wpbr_unclaimed_remote_message($ref) { return sprintf( /* translators: %s is a storage scheme such as vip or s3. */ __('blocked: this file is stored on ā€œ%sā€ storage, and no storage module for it is active, so nothing was changed. WPBR Bulk Replace never writes to storage it cannot verify.', 'wpbr-bulk-replace'), (string) strstr((string) $ref, '://', true) ); } /** The message for a host that cannot be written to yet. */ function wpbr_exclusion_refusal_message(array $decision) { if ($decision['reason'] === 'not_attested') { return __('skipped: this storage cannot guarantee that nothing else changes a file while it is replaced, and no exclusive-writer attestation has been made for it in wp-config.php, so nothing was changed. See Storage > Enterprise for the exact line and what it promises.', 'wpbr-bulk-replace'); } return __('skipped: this storage offers no way to replace a file safely (no conditional write and no exclusive-writer contract), so nothing was changed.', 'wpbr-bulk-replace'); } /** * Replace the file a row points at, on host-managed storage. Mirrors wpbr_process_one_item() for a reference that is not a local path. * * @return array A run result. */ function wpbr_process_remote_item($item, $batch, $live, $file_url) { global $wpdb; $items_table = wpbr_table_items(); $adapter = wpbr_storage_adapter_for($live); if (!$adapter || !$adapter->capabilities()->get('remote')) { return wpbr_run_result('failed', $item->staged_filename, wpbr_unclaimed_remote_message($live), 'blocked_unsupported_storage'); } // Told before anything is asked of the adapter, so an add-on that decides whether NEW work may start (its entitlement) knows which // row and batch it is deciding for. It cannot change what happens next: it answers through the adapter's own preflight. do_action('wpbr_remote_row_start', $item, $batch, $adapter->id()); // Backups and the staged file are LOCAL and private by rule: a folder derived from the platform's own uploads location is not. if (wpbr_ref_is_stream_uri(wpbr_backups_dir()) || wpbr_ref_is_stream_uri($item->staged_path)) { return wpbr_run_result('failed', $item->staged_filename, __('blocked: this site stores uploads on the platform, so the backups and staging folders cannot default to the uploads folder. Choose a private local folder for them first (Storage > Private location, or a path pinned in wp-config.php). Nothing was changed.', 'wpbr-bulk-replace'), 'blocked_unsupported_storage'); } if (!wpbr_remote_ref_allowed($live)) { return wpbr_run_result('skipped', $item->staged_filename, sprintf( /* translators: %s is a stored file URL. */ __('skipped: ā€œ%sā€ resolves outside the uploads directory', 'wpbr-bulk-replace'), $file_url ), 'skipped_outside_uploads'); } if (wpbr_live_path_is_held($live, (string) $item->backup_path)) { return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: %s is a filename. */ __('blocked: an earlier replacement of %s could not be undone and is waiting for attention, so this file is not touched again. Nothing was changed. Deal with that row first.', 'wpbr-bulk-replace'), basename($live) ), 'blocked_needs_attention'); } if (!file_exists($item->staged_path)) { return wpbr_run_result('skipped', $item->staged_filename, __('skipped: staged file missing', 'wpbr-bulk-replace'), 'skipped_staged_missing'); } $decision = wpbr_storage_exclusion_decision($adapter); if (!$decision['ok']) { return wpbr_run_result('skipped', $item->staged_filename, wpbr_exclusion_refusal_message($decision), 'skipped_storage_unsupported'); } $pre = $adapter->preflight($live); if (!$pre->is_ok()) { if (in_array($pre->status, array(WPBR_Storage_Result::DENIED, WPBR_Storage_Result::UNSUPPORTED), true)) { return wpbr_run_result('skipped', $item->staged_filename, __('skipped: this site is not allowed to replace files on its storage, so nothing was changed.', 'wpbr-bulk-replace'), 'skipped_storage_unsupported'); } return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the storage did not answer, so nothing was changed. Run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_storage_unavailable'); } $seen = $adapter->inspect($live, true); if (!$seen->is_ok() || !$seen->fingerprint) { if ($seen->status === WPBR_Storage_Result::NOT_FOUND) { return wpbr_run_result('skipped', $item->staged_filename, sprintf( /* translators: %s is a file URL. */ __('skipped: %s was not found in the storage. Nothing was written: a missing file on host-managed storage is not repaired.', 'wpbr-bulk-replace'), $file_url ), 'skipped_storage_unavailable'); } return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the storage could not be read, so nothing was changed. Run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_storage_unavailable'); } $backup_path = ($item->backup_path && file_exists($item->backup_path)) ? $item->backup_path : ''; if ($backup_path && !empty($item->original_sha256) && wpbr_file_sha256($backup_path) !== $item->original_sha256) { return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: %s is a file path. */ __('blocked: the backup kept at %s no longer matches the file it was taken from, so it cannot be trusted as the original. Nothing was changed. Keep that file and ask for help before running this row again.', 'wpbr-bulk-replace'), $backup_path ), 'blocked_backup_changed'); } // A row that died after recording its intent: conclude from the facts (a lost reply is resolved by looking, never by assuming). $landed = false; if (wpbr_intent_pending($item)) { $class = wpbr_intent_classify($item, $adapter, $live); if ($class === 'original') { wpbr_intent_clear($item->id); } elseif ($class === 'replacement' && $backup_path) { $landed = true; } elseif ($class === 'unknown') { return wpbr_hold_unknown_intent($item, $live, $backup_path); } } // A backup kept from an earlier run describes the object as it WAS. If the object is something else now (someone uploaded a newer // version after a refused or lost write), that backup is of the wrong bytes and a write based on it would overwrite the newer // version with nothing to roll back to. Let it go and take a fresh one of what is there. if ($backup_path && !$landed && !empty($item->original_sha256) && !hash_equals((string) $item->original_sha256, (string) $seen->fingerprint->sha256)) { wpbr_intent_clear($item->id); // 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($items_table, array('backup_path' => null, 'original_sha256' => null), array('id' => $item->id)); wpbr_release_backup_protection($backup_path); $item->backup_path = null; $item->original_sha256 = null; $backup_path = ''; } $base = $seen->fingerprint; if (!$backup_path && !$landed) { $refusal = wpbr_backup_gate_refusal($item, $live); if ($refusal) { return $refusal; } if (function_exists('wpbr_storage_active_dir') && wpbr_storage_active_dir() !== '') { $room = wpbr_storage_check_room(wpbr_backups_dir(), array((int) $seen->fingerprint->size)); if ($room['status'] === 'fail') { return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: 1: a file URL, 2: how much space is needed and how much is free. */ __('failed: not enough free space in the backups folder to back up %1$s safely (%2$s). Nothing was overwritten.', 'wpbr-bulk-replace'), $file_url, $room['detail'] ), 'failed_backup_copy'); } } $backup_path = wpbr_backups_dir() . '/' . wp_unique_filename(wpbr_backups_dir(), $item->matched_post_id . '_' . time() . '_' . basename($live)); $backed_up = $adapter->backup($live, $backup_path); if ($backed_up->status === WPBR_Storage_Result::UNAVAILABLE || $backed_up->status === WPBR_Storage_Result::NOT_FOUND) { return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the file could not be read from the storage to back it up, so nothing was changed. Run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_storage_unavailable'); } if (!$backed_up->is_ok()) { wp_delete_file($backup_path); return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: %s is a file URL. */ __('failed: the backup of %s did not match the file, so nothing was overwritten. Try this row again.', 'wpbr-bulk-replace'), $file_url ), 'failed_backup_unverified'); } // The base the write is conditioned on: what was just backed up. Bytes from the download, identity from the look before it, // so a change between the two is a refusal at write time rather than a silent overwrite. $base = new WPBR_Storage_Fingerprint($backed_up->fingerprint->sha256, $backed_up->fingerprint->size, $seen->fingerprint->identity, $seen->fingerprint->mtime, $seen->fingerprint->source); $item->original_sha256 = wpbr_file_sha256($backup_path); // 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. if (false === $wpdb->update($items_table, array('backup_path' => $backup_path, 'original_sha256' => $item->original_sha256), array('id' => $item->id))) { return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: %s is a file path. */ __('failed: the backup was written to %s but the database would not record it, so nothing was overwritten. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'), $backup_path ), 'failed_backup_not_recorded'); } // The same announcement the local path makes: this backup is now the only copy of an original that is about to change, so a // module that keeps a second copy elsewhere (Cloud Storage) must hear about it. Nothing it returns is read. do_action('wpbr_backup_recorded', $item, $backup_path, $item->original_sha256); } elseif ($backup_path && !$landed) { // A backup kept from an earlier run: the base is what the backup is, and the object must still be exactly that. $base = new WPBR_Storage_Fingerprint((string) $item->original_sha256, null, $seen->fingerprint->identity); } $new_sha = wpbr_file_sha256($item->staged_path); if (!$landed) { $base_sha = wpbr_file_sha256($backup_path); if ($base_sha === '' || $new_sha === '' || !wpbr_intent_begin($item, $adapter, $base_sha, $new_sha, $backup_path, $live)) { return wpbr_run_result('failed', $item->staged_filename, sprintf( /* translators: %s is a file path. */ __('failed: the backup was written to %s but the database would not record it, so nothing was overwritten. Nothing on the site has changed; try this row again.', 'wpbr-bulk-replace'), $backup_path ), 'failed_backup_not_recorded'); } $written = $adapter->write($live, $item->staged_path, $base); $step = wpbr_remote_conclude_write($item, $adapter, $live, $backup_path, $written); if ($step !== null) { return $step; } } return wpbr_commit_remote_item($item, $adapter, $batch, $live, $file_url, $backup_path, $new_sha); } /** * What to do with the adapter's answer to a write. Returns a run result to stop with, or null when the replacement is confirmed * (the caller then commits). */ function wpbr_remote_conclude_write($item, WPBR_Storage_Adapter $adapter, $live, $backup_path, WPBR_Storage_Result $written) { global $wpdb; $new_sha = wpbr_file_sha256($item->staged_path); if ($written->status === WPBR_Storage_Result::PRECONDITION_FAILED) { // Someone else changed the object after it was backed up. Their bytes stay; the earlier copy is detached so a re-run takes // a fresh one of what is there now instead of overwriting the newer bytes from a stale base. wpbr_intent_clear($item->id); // 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(), array('backup_path' => null, 'original_sha256' => null), array('id' => $item->id)); wpbr_release_backup_protection($backup_path); return wpbr_run_result('skipped', $item->staged_filename, sprintf( /* translators: 1: a file URL, 2: a file path (the earlier copy). */ __('skipped: %1$s was changed by something else after it was backed up, so it was left exactly as it is now and nothing was overwritten. A copy of the earlier version is kept at %2$s. Check the file, then run this row again.', 'wpbr-bulk-replace'), $live, $backup_path ), 'skipped_storage_conflict'); } if (in_array($written->status, array(WPBR_Storage_Result::DENIED, WPBR_Storage_Result::UNSUPPORTED), true)) { wpbr_intent_clear($item->id); wpbr_release_backup_protection($backup_path); return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the storage refused the write, so nothing was changed.', 'wpbr-bulk-replace'), 'skipped_storage_unsupported'); } if ($written->status === WPBR_Storage_Result::UNAVAILABLE) { // Known not written (the adapter says it did not reach the object): the object is as it was. wpbr_intent_clear($item->id); wpbr_release_backup_protection($backup_path); return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the storage did not accept the write, so nothing was changed. Run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_storage_unavailable'); } // ok, unknown_outcome and corrupt are all decided the same way: by looking, from the authority, at what is there now. $class = wpbr_intent_classify((object) array( 'intent_base_sha' => wpbr_file_sha256($backup_path), 'intent_new_sha' => $new_sha, ), $adapter, $live); if ($class === 'replacement') { return null; // our bytes are there, whatever the reply said: confirmed } if ($class === 'original') { // Did not land. After an answer of "ok" that is a lie to be held, not retried: the provider said it wrote and did not. if ($written->status === WPBR_Storage_Result::OK) { return wpbr_hold_unknown_intent($item, $live, $backup_path); } wpbr_intent_clear($item->id); wpbr_release_backup_protection($backup_path); return wpbr_run_result('skipped', $item->staged_filename, __('skipped: the storage did not confirm the write and the file is as it was, so nothing was changed. Run this row again in a moment.', 'wpbr-bulk-replace'), 'skipped_storage_unavailable'); } // Neither the original nor the replacement: not ours to fix, and never an automatic restore over unknown bytes. return wpbr_hold_unknown_intent($item, $live, $backup_path); } /** Record a confirmed replacement on host-managed storage, then ask the platform to refresh what it serves. */ function wpbr_commit_remote_item($item, WPBR_Storage_Adapter $adapter, $batch, $live, $file_url, $backup_path, $new_sha) { global $wpdb; $items_table = wpbr_table_items(); wpbr_asset_version_register($live); $attachment_id = wpbr_attachment_id_for_path($live, $file_url); if ($attachment_id) { // Documents have no derived sizes to rebuild on host-managed storage; the post is touched and the recorded size refreshed. $meta = wp_get_attachment_metadata($attachment_id); if (is_array($meta) && array_key_exists('filesize', $meta)) { $meta['filesize'] = (int) @filesize($item->staged_path); wp_update_attachment_metadata($attachment_id, $meta); } wp_update_post(array('ID' => $attachment_id, 'post_modified' => current_time('mysql'), 'post_modified_gmt' => current_time('mysql', true))); } // The answer to "is the edge serving the new bytes" is never claimed here; only whether a refresh was requested. $purge = $adapter->after_commit($live); $note = $purge->is_ok() && $purge->stage === 'purge_requested' ? __('A refresh of the platform cache was requested; it can take a while before every visitor sees the new file.', 'wpbr-bulk-replace') : __('The platform cache was not refreshed by WPBR Bulk Replace; visitors may keep seeing the old file until it expires.', 'wpbr-bulk-replace'); // 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($items_table, array( 'status' => 'replaced', 'backup_path' => $backup_path, 'processed_at' => current_time('mysql'), 'notes' => trim(($item->notes ? $item->notes . ' | ' : '') . $note), ) + wpbr_intent_committed($new_sha), array('id' => $item->id)); wpbr_release_backup_protection($backup_path); // the write is concluded; the backup is an ordinary one again return wpbr_run_result('success', $item->staged_filename, sprintf( /* translators: %s is a file path. */ __('replaced; backup kept at %s', 'wpbr-bulk-replace'), $backup_path ), 'replaced'); } /** * Roll a row back on host-managed storage. The object is put back only if it still is exactly what this row wrote; anything else is * someone else's change and is left alone. * * @return array{outcome:string,message:string} */ function wpbr_rollback_remote_item($item, $batch, $live, $file_url) { global $wpdb; $adapter = wpbr_storage_adapter_for($live); if (!$adapter || !$adapter->capabilities()->get('remote')) { return array('outcome' => 'blocked_unsupported_storage', 'message' => wpbr_unclaimed_remote_message($live)); } if (!wpbr_remote_ref_allowed($live)) { return array('outcome' => 'unavailable_path_unresolvable', 'message' => __('cannot be rolled back: the file is not inside the uploads folder', 'wpbr-bulk-replace')); } $decision = wpbr_storage_exclusion_decision($adapter); if (!$decision['ok']) { return array('outcome' => 'skipped_storage_unsupported', 'message' => wpbr_exclusion_refusal_message($decision)); } if (!empty($item->original_sha256) && wpbr_file_sha256($item->backup_path) !== $item->original_sha256) { return array('outcome' => 'unavailable_backup_changed', 'message' => __('cannot be rolled back: the backup no longer matches the file it was taken from, so it was not put back. Nothing was changed.', 'wpbr-bulk-replace')); } $now = $adapter->inspect($live, true); if (!$now->is_ok() || !$now->fingerprint) { return array('outcome' => 'skipped_storage_unavailable', 'message' => __('skipped: the storage could not be read, so nothing was changed. Roll this row back again in a moment.', 'wpbr-bulk-replace')); } $written = isset($item->written_sha) ? (string) $item->written_sha : ''; if ($written === '' || !hash_equals($written, $now->fingerprint->sha256)) { return array('outcome' => 'skipped_storage_conflict', 'message' => __('skipped: the file is no longer the one this row put there, so it was left exactly as it is and nothing was overwritten.', 'wpbr-bulk-replace')); } $restored = $adapter->restore($item->backup_path, $live, $now->fingerprint); if ($restored->status === WPBR_Storage_Result::PRECONDITION_FAILED) { return array('outcome' => 'skipped_storage_conflict', 'message' => __('skipped: the file was changed by something else while it was being rolled back, so it was left exactly as it is and nothing was overwritten.', 'wpbr-bulk-replace')); } $after = $adapter->inspect($live, true); $good = hash_equals((string) $item->original_sha256, $after->fingerprint ? $after->fingerprint->sha256 : ''); if (!$good) { if ($restored->status === WPBR_Storage_Result::UNAVAILABLE || $restored->status === WPBR_Storage_Result::DENIED || $restored->status === WPBR_Storage_Result::UNSUPPORTED) { return array('outcome' => 'failed_restore_copy', 'message' => __('failed: restoring backup', 'wpbr-bulk-replace')); } // The write may or may not have landed and what is there is not the original: hold it, never write over it. $backup_sha = wpbr_file_sha256($item->backup_path); $held = wpbr_mark_needs_attention($item, $live, $item->backup_path, 'rollback_restore_failed', $backup_sha); return array( 'outcome' => 'needs_attention_restore_failed', 'message' => wpbr_needs_attention_message($item, $live, $item->backup_path, true, $held['marker'], $held['checked_at']), ); } wpbr_release_backup_protection($item->backup_path); wpbr_asset_version_register($live); $adapter->after_commit($live); // 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(), array( 'status' => 'matched', 'processed_at' => null, 'notes' => trim(($item->notes ? $item->notes . ' | ' : '') . __('Rolled back: previous file restored from backup', 'wpbr-bulk-replace')), ) + wpbr_intent_cleared(), array('id' => $item->id)); return array('outcome' => 'rolled_back', 'message' => __('rolled back: previous file restored', 'wpbr-bulk-replace')); } // -- Retry restore on host-managed storage ---------------------------------------------------------------------------------------- // The interactive way out of needs_attention (includes/retry-restore.php) asks four things of the file: what is it now, may I keep a copy // of it, put the original back, and prove it. For a local file those are filesystem calls; for a host-managed reference they go through // the adapter that owns it, with the same rules: unknown bytes only on a confirmation bound to what the person was shown, and the original // put back only over exactly that. /** * What the host-managed object is now. Same classes as the local classifier except the two partial ones, which need a prefix read of the * file and so come back as `unexpected` (the safe reading: it needs a person's confirmation). * * @return array{class:string,fingerprint:?array,size:?int} */ function wpbr_retry_classify_remote($live, $staged_path, $original_sha256) { $adapter = wpbr_storage_adapter_for($live); if (!$adapter) { return array('class' => 'unexpected', 'fingerprint' => null, 'size' => null); } $seen = $adapter->inspect($live, true); if ($seen->status === WPBR_Storage_Result::NOT_FOUND) { return array('class' => 'absent', 'fingerprint' => null, 'size' => null); } if (!$seen->is_ok() || !$seen->fingerprint || $seen->fingerprint->sha256 === '') { return array('class' => 'unexpected', 'fingerprint' => null, 'size' => null); } $f = $seen->fingerprint; $size = (int) $f->size; $fp = array('sha256' => $f->sha256, 'size' => $size, 'mtime' => (int) $f->mtime); if ($size === 0) { return array('class' => 'empty', 'fingerprint' => $fp, 'size' => 0); } if (hash_equals((string) $original_sha256, $f->sha256)) { return array('class' => 'original', 'fingerprint' => $fp, 'size' => $size); } if ($staged_path && is_file($staged_path) && hash_equals(wpbr_file_sha256($staged_path), $f->sha256)) { return array('class' => 'replacement', 'fingerprint' => $fp, 'size' => $size); } return array('class' => 'unexpected', 'fingerprint' => $fp, 'size' => $size); } /** * Keep a verified, protected copy of what is there before it is overwritten, as the local path does. * * @return string The path of the kept copy, or '' when it could not be made, verified and protected. */ function wpbr_retry_keep_remote($live, array $fingerprint, $item) { $adapter = wpbr_storage_adapter_for($live); if (!$adapter) { return ''; } $dir = wpbr_backups_dir(); $dest = $dir . '/' . wp_unique_filename($dir, basename($item->backup_path) . '.unexpected-' . substr((string) $fingerprint['sha256'], 0, 12)); $copy = $adapter->backup($live, $dest); if (!$copy->is_ok() || !$copy->fingerprint || !hash_equals((string) $fingerprint['sha256'], $copy->fingerprint->sha256)) { wp_delete_file($dest); return ''; } $marked = wpbr_protect_backup($dest, array( 'sha256' => $fingerprint['sha256'], 'original_sha256' => '', 'live_path' => '', 'item_id' => (int) $item->id, 'batch_id' => (int) $item->batch_id, 'reason' => 'unexpected_contents_kept_before_restore', )); if (!$marked || !is_file(wpbr_protection_sidecar_path($dest))) { wp_delete_file($dest); return ''; } return $dest; } /** * Put the original back over exactly what was inspected, and say what happened in the engine's stage words. * * @param array|null $expected {sha256,size,mtime} of what the object must still be, or null for an absent or empty object. * @return array{stage:string,verified:bool} stage: written | conflict | unavailable */ function wpbr_retry_write_remote($backup, $live, $expected, $original_sha256) { $adapter = wpbr_storage_adapter_for($live); if (!$adapter || !wpbr_storage_exclusion_decision($adapter)['ok']) { return array('stage' => 'unavailable', 'verified' => false); } $base = is_array($expected) ? new WPBR_Storage_Fingerprint((string) $expected['sha256'], isset($expected['size']) ? (int) $expected['size'] : null, '', isset($expected['mtime']) ? (int) $expected['mtime'] : null) : null; $r = $adapter->restore($backup, $live, $base); if ($r->status === WPBR_Storage_Result::PRECONDITION_FAILED) { return array('stage' => 'conflict', 'verified' => false); } $after = $adapter->inspect($live, true); $verified = $after->is_ok() && $after->fingerprint && hash_equals((string) $original_sha256, $after->fingerprint->sha256); return array('stage' => $r->status === WPBR_Storage_Result::OK || $r->status === WPBR_Storage_Result::UNKNOWN_OUTCOME ? 'written' : 'unavailable', 'verified' => (bool) $verified); }