PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-activator.php +409 -35 2.6.5 → 2.7.1 View file →
@@ -23,8 +23,59 @@
23 23 class Metasync_Activator
24 24 {
25 25
26 26 /**
27 + * Option recording the last failed White Label JSON import.
28 + * Stores array{file_hash: string, failed_at: int, error: string}.
29 + *
30 + * @var string
31 + */
32 + const IMPORT_FAILURE_OPTION = 'metasync_whitelabel_import_failure';
33 +
34 + /**
35 + * Retry cadence (seconds) for a failing White Label import so a
36 + * broken file cannot produce a silent retry storm on every admin request.
37 + *
38 + * @var int
39 + */
40 + const IMPORT_RETRY_BACKOFF_SECONDS = 3600;
41 +
42 + /**
43 + * Machine-readable reason for the most recent import failure, consumed
44 + * by render_whitelabel_import_failure_notice().
45 + *
46 + * @var string
47 + */
48 + private static $last_import_error = '';
49 +
50 + /**
51 + * Canonical list of MetaSync custom WP-Cron hooks.
52 + * Shared with Metasync_Deactivator so deactivation cleans up every scheduled hook.
53 + *
54 + * @since 2.5.x
55 + * @var string[]
56 + */
57 + public static $cron_hooks = [
58 + 'metasync_sync_log_daily_cleanup',
59 + 'metasync_announce_cron',
60 + 'metasync_rate_limit_cleanup',
61 + 'metasync_heartbeat_cron_check',
62 + 'metasync_burst_heartbeat',
63 + 'metasync_check_debug_limits',
64 + 'metasync_cleanup_transients',
65 + 'metasync_hidden_post_check',
66 + 'metasync_otto_recheck_404_exclusions',
67 + 'metasync_db_cleanup',
68 + 'metasync_media_batch_optimize_cron',
69 + 'metasync_speed_cache_cleanup',
70 + 'metasync_process_seo_job',
71 + 'metasync_process_otto_crawl_url_job',
72 + 'metasync_process_otto_batch_cache_job',
73 + 'metasync_host_blocking_check',
74 + 'metasync_host_blocking_weekly_check',
75 + ];
76 +
77 + /**
27 78 * Short Description. (use period)
28 79 *
29 80 * Long Description.
30 81 *
@@ -41,9 +92,9 @@
41 92 self::ensure_plugin_auth_token();
42 93
43 94 // Import whitelabel settings only if the JSON file is new or changed
44 95 // (prevents overwriting admin UI changes on every deactivate/activate cycle)
45 - self::check_whitelabel_settings_update();
96 + self::check_whitelabel_settings_update(true);
46 97
47 98 // Pre-SSO announce: tell backend plugin is installed (PR4 - heartbeat reliability)
48 99 update_option('metasync_announce_attempt_count', 0);
49 100 self::send_announce_ping();
@@ -53,8 +104,16 @@
53 104 if (!wp_next_scheduled('metasync_announce_cron')) {
54 105 wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'metasync_every_10_minutes', 'metasync_announce_cron');
55 106 }
56 107
108 + // Detect a host that blocks GET/POST between this site and Search Atlas.
109 + // Deferred to cron (~10 minutes out) rather than run here — a blocking HTTP call
110 + // inside the activation request is exactly what has taken slow hosts down before,
111 + // and a firewall-blocked request is the slowest kind there is.
112 + if (class_exists('Metasync_Host_Blocking_Check')) {
113 + Metasync_Host_Blocking_Check::schedule_initial_check();
114 + }
115 +
57 116 // Set first activation flag for setup wizard
58 117 if (!get_option('metasync_first_activation_time')) {
59 118 update_option('metasync_first_activation_time', current_time('mysql'));
60 119 update_option('metasync_show_wizard', true);
@@ -59,9 +118,26 @@
59 118 update_option('metasync_first_activation_time', current_time('mysql'));
60 119 update_option('metasync_show_wizard', true);
61 120 }
62 121
63 - flush_rewrite_rules();
122 + // Auto-disable WP core sitemap if a MetaSync sitemap already exists.
123 + // Skip on fresh installs where no sitemap has been generated yet so the site
124 + // doesn't end up with zero sitemaps.
125 + if (get_option('metasync_sitemap_auto_update', false)
126 + || !empty(get_option('metasync_sitemap_files', []))
127 + || file_exists(ABSPATH . 'sitemap_index.xml')
128 + ) {
129 + update_option('metasync_disable_wp_sitemap', true);
130 + }
131 +
132 + // Soft flush only (no .htaccess rewrite). A hard flush calls
133 + // save_mod_rewrite_rules(), which takes an exclusive flock() on the site's
134 + // root .htaccess. On hosts whose cache/optimizer also holds that lock (e.g.
135 + // SiteGround + SG Optimizer), the activation request blocks until
136 + // max_execution_time and the host returns a 500. MetaSync's rewrite rules are
137 + // registered via add_rewrite_rule (stored in the `rewrite_rules` option, NOT
138 + // in .htaccess), so a soft flush is sufficient and never touches the file.
139 + flush_rewrite_rules(false);
64 140 }
65 141
66 142 /**
67 143 * Send pre-SSO announce ping to backend (zero-trust; backend rate-limits).
@@ -153,21 +229,40 @@
153 229 /**
154 230 * Check if whitelabel settings file has been updated and import if needed
155 231 * This method should be called on init to detect plugin uploads/updates
156 232 *
233 + * @param bool $trusted_update_context True only for core activation/upgrader hooks.
234 + * @return bool Whether the file was already current or imported successfully.
157 235 * @since 2.5.0
158 236 */
159 - public static function check_whitelabel_settings_update()
237 + public static function check_whitelabel_settings_update($trusted_update_context = false)
160 238 {
239 +// Never run the import from AJAX handlers. admin-ajax requests
240 + // (notably the Forgot Password recovery call) run admin_init first, so an
241 + // import attempted there already holds the shared recovery lock when the
242 + // handler itself runs, and every recovery request returns HTTP 429.
243 + // Trusted activation/upgrader contexts run once per update, not per
244 + // request, and stay exempt.
245 + if (!$trusted_update_context && wp_doing_ajax()) {
246 + return false;
247 + }
248 +
249 + if (!$trusted_update_context && !current_user_can('manage_options')) {
250 + return false;
251 + }
252 +
161 253 $json_file = self::get_whitelabel_settings_file();
162 254
163 255 if ($json_file === false) {
164 - return;
256 + return false;
165 257 }
166 258
167 259 // Get current file modification time and content hash
168 260 $file_mtime = filemtime($json_file);
169 261 $file_hash = md5_file($json_file);
262 + if (!is_int($file_mtime) || !is_string($file_hash)) {
263 + return false;
264 + }
170 265
171 266 // Get stored file info
172 267 $stored_mtime = get_option('metasync_whitelabel_file_mtime', 0);
173 268 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
@@ -173,49 +268,123 @@
173 268 $stored_hash = get_option('metasync_whitelabel_file_hash', '');
174 269
175 270 // Check if file has changed (either modification time or content)
176 271 if ($file_mtime > $stored_mtime || $file_hash !== $stored_hash) {
272 + // A failing import must not retry on every admin request.
273 + // Back off per failing file hash; a successful import or a changed
274 + // file clears the state and retries immediately.
275 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
276 + if (is_array($failure)
277 + && isset($failure['file_hash'], $failure['failed_at'])
278 + && (string) $failure['file_hash'] === $file_hash
279 + && (time() - (int) $failure['failed_at']) < self::IMPORT_RETRY_BACKOFF_SECONDS) {
280 + return false;
281 + }
282 +
177 283 // File has changed, import settings
178 - self::import_whitelabel_settings();
284 + if (!self::import_whitelabel_settings()) {
285 + update_option(self::IMPORT_FAILURE_OPTION, array(
286 + 'file_hash' => $file_hash,
287 + 'failed_at' => time(),
288 + 'error' => self::$last_import_error,
289 + ));
290 + return false;
291 + }
292 + delete_option(self::IMPORT_FAILURE_OPTION);
179 293
180 - // Store new file info
294 + // Record the file only after the complete import succeeds.
181 295 update_option('metasync_whitelabel_file_mtime', $file_mtime);
182 296 update_option('metasync_whitelabel_file_hash', $file_hash);
183 297 }
298 +
299 + return true;
184 300 }
185 301
186 302 /**
303 + * Machine-readable reason of the most recent failed White Label import.
304 + *
305 + * @return string
306 + */
307 + public static function get_last_import_error()
308 + {
309 + return self::$last_import_error;
310 + }
311 +
312 + /**
313 + * Admin notice for a White Label JSON import that keeps failing.
314 + * Registered from metasync_check_whitelabel_on_admin(); the state is set
315 + * by check_whitelabel_settings_update() when an import fails.
316 + *
317 + * @return void
318 + */
319 + public static function render_whitelabel_import_failure_notice()
320 + {
321 + if (!current_user_can('manage_options')) {
322 + return;
323 + }
324 + $failure = get_option(self::IMPORT_FAILURE_OPTION, array());
325 + if (!is_array($failure) || empty($failure['file_hash']) || empty($failure['failed_at'])) {
326 + return;
327 + }
328 + $reasons = array(
329 + 'json_read_failed' => 'the file could not be read',
330 + 'json_invalid' => 'the file is not valid JSON',
331 + 'json_structure_invalid' => 'the file structure is invalid',
332 + 'plugin_file_unreadable' => 'metasync.php could not be read',
333 + 'plugin_header_write_failed' => 'the plugin file headers could not be updated (is metasync.php writable?)',
334 + 'recovery_lock_busy' => 'another password operation was in progress',
335 + 'options_write_failed' => 'the settings could not be saved',
336 + );
337 + $reason = isset($failure['error'], $reasons[$failure['error']]) ? $reasons[$failure['error']] : 'an unexpected error occurred';
338 + echo '<div class="notice notice-error"><p><strong>White Label settings import failed.</strong> '
339 + . 'MetaSync could not import <code>whitelabel-settings.json</code>: ' . esc_html($reason)
340 + . '. The import will be retried automatically; fix the file and reload to retry immediately.</p></div>';
341 + }
342 +
343 + /**
187 344 * Import whitelabel settings from JSON file if available
188 345 * Checks for whitelabel-settings.json in the plugin directory or extracted zip
189 346 *
190 - * This method is public to allow calling during both activation and plugin updates.
347 + * @return bool Whether the complete import succeeded.
191 348 * @since 2.5.0
192 349 */
193 - public static function import_whitelabel_settings()
350 + private static function import_whitelabel_settings()
194 351 {
195 352 $json_file = self::get_whitelabel_settings_file();
196 353
197 354 if ($json_file === false) {
198 - return;
355 + return false;
199 356 }
200 357
358 + self::$last_import_error = 'unknown';
359 +
201 360 // Read JSON file
202 361 $json_content = file_get_contents($json_file);
203 362 if ($json_content === false) {
204 - return;
363 + self::$last_import_error = 'json_read_failed';
364 + return false;
205 365 }
206 366
207 367 // Decode JSON
208 368 $import_data = json_decode($json_content, true);
209 369 if ($import_data === null || json_last_error() !== JSON_ERROR_NONE) {
210 - return;
370 + self::$last_import_error = 'json_invalid';
371 + return false;
211 372 }
212 373
213 374 // Validate import data structure
214 375 if (!isset($import_data['whitelabel_settings']) || !is_array($import_data['whitelabel_settings'])) {
215 - return;
376 + self::$last_import_error = 'json_structure_invalid';
377 + return false;
216 378 }
217 379
380 + $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
381 + $original_plugin_content = file_get_contents($plugin_file);
382 + if ($original_plugin_content === false) {
383 + self::$last_import_error = 'plugin_file_unreadable';
384 + return false;
385 + }
386 +
218 387 // Get current options
219 388 $options = get_option('metasync_options', array());
220 389
221 390 // Import whitelabel settings
@@ -229,8 +398,14 @@
229 398
230 399 // Merge imported settings with existing (imported settings take precedence)
231 400 $options['whitelabel'] = array_merge($options['whitelabel'], $whitelabel_settings);
232 401
402 + // The settings password travels as plaintext in the export file so it
403 + // can be imported on a site with different salts — encrypt it at rest.
404 + if (!empty($options['whitelabel']['settings_password']) && is_string($options['whitelabel']['settings_password'])) {
405 + $options['whitelabel']['settings_password'] = Metasync::encrypt_secret($options['whitelabel']['settings_password']);
406 + }
407 +
233 408 // Update timestamp
234 409 $options['whitelabel']['updated_at'] = time();
235 410 $options['whitelabel']['imported_at'] = current_time('mysql');
236 411 }
@@ -246,8 +421,17 @@
246 421 $options['general'][$key] = $value;
247 422 }
248 423 }
249 424
425 + // Validate and persist the public plugin headers before any other import side effect.
426 + $header_data = array(
427 + 'general_settings' => $options['general'] ?? array(),
428 + );
429 + if (!self::update_plugin_file_headers($header_data, $plugin_file)) {
430 + self::$last_import_error = 'plugin_header_write_failed';
431 + return false;
432 + }
433 +
250 434 // Restore bundled icon: if the icon value is a __bundled_icon__{ext} marker,
251 435 // copy the bundled file from the plugin directory to uploads and update the URL.
252 436 $icon_value = $options['general']['white_label_plugin_menu_icon'] ?? '';
253 437 if (!empty($icon_value) && strpos($icon_value, '__bundled_icon__') === 0) {
@@ -273,19 +457,80 @@
273 457 $options['general']['white_label_plugin_menu_icon'] = '';
274 458 }
275 459 }
276 460
277 - // Save updated options
278 - update_option('metasync_options', $options);
461 + // Save updated options.
462 +// when the package carries a settings password this is a
463 + // legitimate import write, but the recovery protection filter treats any
464 + // unauthenticated password write as a conflict and swaps it back to the
465 + // stored password — the verification below could then never pass once a
466 + // password is stored, and the retry loop would hold the shared recovery
467 + // lock on every admin request, blocking the Forgot Password flow.
468 + // Mirror the recovery flow's own persist path: take the lock and
469 + // authorize the write for the duration of the save.
470 + $carries_password = !empty($options['whitelabel']['settings_password']);
471 + $import_lock_owner = '';
472 + if ($carries_password) {
473 + require_once __DIR__ . '/class-metasync-admin-ajax.php';
474 + require_once __DIR__ . '/class-metasync-settings-registration.php';
475 + if (!self::class_supports_methods('Metasync_Admin_Ajax', array('acquire_recovery_lock', 'release_recovery_lock'))
476 + || !self::class_supports_methods('Metasync_Settings_Registration', array('authorize_recovery_password_write'))) {
477 + // The updater can replace plugin files after the old classes were
478 + // loaded in this request. Defer the import to the next request.
479 + self::$last_import_error = 'recovery_api_unavailable';
480 + return false;
481 + }
482 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($import_lock_owner)) {
483 + // A recovery or settings save is mid-write; retry via the normal
484 + // backoff instead of fighting for the lock.
485 + self::$last_import_error = 'recovery_lock_busy';
486 + return false;
487 + }
488 + }
489 + try {
490 + if ($carries_password) {
491 + Metasync_Settings_Registration::authorize_recovery_password_write(true);
492 + }
493 + update_option('metasync_options', $options);
494 + } finally {
495 + if ($carries_password) {
496 + Metasync_Settings_Registration::authorize_recovery_password_write(false);
497 + Metasync_Admin_Ajax::instance()->release_recovery_lock($import_lock_owner);
498 + }
499 + }
500 + if (get_option('metasync_options', null) !== $options) {
501 + if (self::atomically_replace_plugin_file($plugin_file, $original_plugin_content)) {
502 + wp_cache_delete('plugins', 'plugins');
503 + }
504 + self::$last_import_error = 'options_write_failed';
505 + return false;
506 + }
279 507
280 - // Update the plugin file headers so whitelabel shows even when deactivated
281 - self::update_plugin_file_headers($import_data);
282 -
283 508 // Optionally delete the JSON file after successful import (uncomment if desired)
284 509 // unlink($json_file);
510 +
511 + return true;
285 512 }
286 513
287 514 /**
515 + * Check runtime class compatibility without letting static analysis fold it.
516 + *
517 + * @param string $class_name Class name loaded in the current request.
518 + * @param string[] $methods Required method names.
519 + * @return bool
520 + */
521 + private static function class_supports_methods($class_name, $methods)
522 + {
523 + foreach ($methods as $method) {
524 + if (!method_exists($class_name, $method)) {
525 + return false;
526 + }
527 + }
528 +
529 + return true;
530 + }
531 +
532 + /**
288 533 * Sync plugin file headers from the current saved options in the database.
289 534 * Call this after saving whitelabel settings via the admin UI to ensure
290 535 * the plugin file headers reflect the latest whitelabel values.
291 536 *
@@ -292,8 +537,12 @@
292 537 * @since 2.5.0
293 538 */
294 539 public static function sync_plugin_file_headers()
295 540 {
541 + if (!current_user_can('manage_options')) {
542 + return false;
543 + }
544 +
296 545 $options = get_option('metasync_options', array());
297 546 $general = $options['general'] ?? array();
298 547
299 548 // Build the import_data format expected by update_plugin_file_headers
@@ -300,9 +549,9 @@
300 549 $import_data = array(
301 550 'general_settings' => $general,
302 551 );
303 552
304 - self::update_plugin_file_headers($import_data);
553 + return self::update_plugin_file_headers($import_data);
305 554 }
306 555
307 556 /**
308 557 * Update the main plugin file headers with whitelabel values
@@ -311,19 +560,19 @@
311 560 *
312 561 * @param array $import_data The imported whitelabel data
313 562 * @since 2.5.0
314 563 */
315 - private static function update_plugin_file_headers($import_data)
564 + private static function update_plugin_file_headers($import_data, $plugin_file = null)
316 565 {
317 - $plugin_file = plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
566 + $plugin_file = $plugin_file ?: plugin_dir_path(dirname(__FILE__)) . 'metasync.php';
318 567
319 - if (!file_exists($plugin_file) || !is_writable($plugin_file)) {
320 - return;
568 + if (!file_exists($plugin_file) || !wp_is_writable($plugin_file)) {
569 + return false;
321 570 }
322 571
323 572 $content = file_get_contents($plugin_file);
324 573 if ($content === false) {
325 - return;
574 + return false;
326 575 }
327 576
328 577 $general = $import_data['general_settings'] ?? array();
329 578
@@ -331,9 +580,9 @@
331 580 // with default values to restore when whitelabel is cleared
332 581 $header_map = array(
333 582 'white_label_plugin_name' => array(
334 583 'header' => 'Plugin Name',
335 - 'default' => 'Search Atlas: The Premier AI SEO Plugin for Instant Optimization',
584 + 'default' => 'Search Atlas SEO - OTTO AI SEO Automation for WordPress',
336 585 ),
337 586 'white_label_plugin_description' => array(
338 587 'header' => 'Description',
339 588 'default' => 'Search Atlas SEO is an intuitive WordPress Plugin that transforms the most complicated, most labor-intensive SEO tasks into streamlined, straightforward processes. With a few clicks, the meta-bulk update feature automates the re-optimization of meta tags using AI to increase clicks. Stay up-to-date with the freshest Google Search data for your entire site or targeted URLs within the Meta Sync plug-in page.',
@@ -352,8 +601,10 @@
352 601 ),
353 602 );
354 603
355 604 $modified = false;
605 + $original_content = $content;
606 + $url_fields = array('white_label_plugin_author_uri', 'white_label_plugin_uri');
356 607
357 608 foreach ($header_map as $setting_key => $field_config) {
358 609 $header_field = $field_config['header'];
359 610
@@ -361,27 +612,150 @@
361 612 $new_value = !empty($general[$setting_key])
362 613 ? $general[$setting_key]
363 614 : $field_config['default'];
364 615
616 + if (!is_string($new_value) || !self::is_valid_plugin_header_value($new_value, in_array($setting_key, $url_fields, true))) {
617 + return false;
618 + }
619 +
365 620 // Match the header line: " * Field Name: any value"
366 621 // Handles varying whitespace between field name and value
367 622 $pattern = '/^(\s*\*\s*' . preg_quote($header_field, '/') . ':\s*)(.+)$/m';
368 623
369 - if (preg_match($pattern, $content, $matches)) {
370 - // Only replace if the value actually differs from what's in the file
371 - if (trim($matches[2]) !== trim($new_value)) {
372 - // Escape both backslashes and $ signs for preg_replace replacement string
373 - $escaped_value = str_replace(array('\\', '$'), array('\\\\', '\\$'), $new_value);
374 - $content = preg_replace($pattern, '${1}' . $escaped_value, $content, 1);
375 - $modified = true;
624 + if (preg_match_all($pattern, $content, $matches) !== 1) {
625 + return false;
626 + }
627 +
628 + // Only replace if the value actually differs from what's in the file.
629 + if (trim($matches[2][0]) !== trim($new_value)) {
630 + $content = preg_replace_callback(
631 + $pattern,
632 + static function ($match) use ($new_value) {
633 + return $match[1] . $new_value;
634 + },
635 + $content,
636 + 1,
637 + $replacement_count
638 + );
639 +
640 + if (!is_string($content) || $replacement_count !== 1) {
641 + return false;
376 642 }
643 + $modified = true;
377 644 }
378 645 }
379 646
380 - if ($modified) {
381 - file_put_contents($plugin_file, $content);
647 + if (!self::is_valid_transformed_plugin_file($original_content, $content, $header_map)) {
648 + return false;
649 + }
382 650
383 - // Clear WordPress plugin cache so it reads the updated headers
384 - wp_cache_delete('plugins', 'plugins');
651 + if (!$modified) {
652 + return true;
385 653 }
654 +
655 + if (!self::atomically_replace_plugin_file($plugin_file, $content)) {
656 + return false;
657 + }
658 +
659 + // Clear WordPress plugin cache so it reads the updated headers.
660 + wp_cache_delete('plugins', 'plugins');
661 +
662 + return true;
663 + }
664 +
665 + /**
666 + * Validate a value before placing it inside the plugin's PHP docblock.
667 + */
668 + private static function is_valid_plugin_header_value($value, $is_url)
669 + {
670 + if (
671 + $value === ''
672 + || preg_match('/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u', $value) !== 1
673 + || preg_match('/\*\/|<\?php|\?>/i', $value) !== 0
674 + ) {
675 + return false;
676 + }
677 +
678 + if (!$is_url) {
679 + return true;
680 + }
681 +
682 + if (filter_var($value, FILTER_VALIDATE_URL) === false) {
683 + return false;
684 + }
685 +
686 + $scheme = strtolower((string) wp_parse_url($value, PHP_URL_SCHEME));
687 +
688 + return in_array($scheme, array('http', 'https'), true);
689 + }
690 +
691 + /**
692 + * Validate the complete transformed PHP file and its header structure.
693 + */
694 + private static function is_valid_transformed_plugin_file($original, $transformed, $header_map)
695 + {
696 + if (!is_string($transformed) || strpos($transformed, '<?php') !== 0) {
697 + return false;
698 + }
699 +
700 + if (
701 + substr_count($original, '<?php') !== substr_count($transformed, '<?php')
702 + || substr_count($original, '?>') !== substr_count($transformed, '?>')
703 + ) {
704 + return false;
705 + }
706 +
707 + foreach ($header_map as $field_config) {
708 + $pattern = '/^\s*\*\s*' . preg_quote($field_config['header'], '/') . ':\s*.+$/m';
709 + if (preg_match_all($pattern, $transformed) !== 1) {
710 + return false;
711 + }
712 + }
713 +
714 + try {
715 + $tokens = token_get_all($transformed, TOKEN_PARSE);
716 + } catch (ParseError $error) {
717 + return false;
718 + }
719 +
720 + return true;
721 + }
722 +
723 + /**
724 + * Replace a plugin file from a same-directory temporary file.
725 + */
726 + private static function atomically_replace_plugin_file($plugin_file, $content, $rename_file = null)
727 + {
728 + $directory = dirname($plugin_file);
729 + $temp_file = tempnam($directory, '.metasync-header-');
730 + if ($temp_file === false) {
731 + return false;
732 + }
733 +
734 + $permissions = fileperms($plugin_file);
735 + $written = file_put_contents($temp_file, $content, LOCK_EX);
736 + if ($written !== strlen($content)) {
737 + @wp_delete_file($temp_file);
738 + return false;
739 + }
740 +
741 + if ($permissions !== false) {
742 + @chmod($temp_file, $permissions & 0777); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- direct POSIX permission change; WP_Filesystem would prompt for FTP credentials on non-direct hosts
743 + }
744 +
745 + if ($rename_file === null) {
746 + $rename_file = static function ($source, $destination) {
747 + return @rename($source, $destination); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
748 + };
749 + }
750 +
751 + // The temporary file is on the same filesystem, so a successful rename is
752 + // atomic. On failure the original path has not been touched.
753 + if ($rename_file($temp_file, $plugin_file)) {
754 + return true;
755 + }
756 +
757 + @wp_delete_file($temp_file);
758 +
759 + return false;
386 760 }
387 761 }