ENG7-4430: Harden emergency restore metadata storage#634
Conversation
Store the per-install CLI secret and restore-info JSON in the backup directory instead of web-served plugin paths, rotate away from legacy verify files that could ship in release zips, and use timing-safe secret comparisons for cron restore validation.
There was a problem hiding this comment.
Pull request overview
This PR hardens Total Upkeep’s “emergency restore”/cron restore plumbing by moving secrets and restore metadata out of web-served plugin paths (into the backup directory when available), tightening on-disk and web-access controls, and improving request validation.
Changes:
- Relocate and migrate the per-install CLI secret +
restore-info-*.jsonout of the plugin web tree; add locator/secret persistence + legacy cleanup. - Harden cron/CLI web exposure (directory hardening, release stripping of sensitive artifacts) and use timing-safe comparisons for cron restore validation.
- Update packaging/CI + dependency metadata and bump version/docs to 1.17.3.
Reviewed changes
Copilot reviewed 17 out of 21 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
tests/cli/test-class-info.php |
Adds coverage for secure storage migration/rotation behavior. |
readme.txt |
Updates stable tag/changelog/upgrade notice for 1.17.3 security release. |
includes/class-boldgrid-backup.php |
Hooks secure CLI storage migration into admin init. |
includes/class-boldgrid-backup-activator.php |
Runs secure storage setup on activation when backup dir is known. |
cron/index.php |
Adds “silence is golden” placeholder to reduce directory exposure. |
cron/.htaccess |
Denies Apache web access to cron directory and disables indexing. |
composer.lock |
Updates locked dependencies and Composer metadata. |
composer.json |
Pins Composer platform PHP and adds audit ignore metadata. |
cli/index.php |
Adds “silence is golden” placeholder to reduce directory exposure. |
cli/env-info.php |
Switches to timing-safe secret comparison for standalone probe. |
cli/class-info.php |
Implements secure storage, locator, secret persistence, and legacy cleanup/migration. |
cli/.htaccess |
Denies Apache web access to locator/verify files and disables indexing. |
boldgrid-backup.php |
Bumps plugin version and adjusts legacy restore-info migration logic. |
bin/release.sh |
Strips per-install secret/restore-info artifacts from release tree. |
admin/class-boldgrid-backup-admin-folder-exclusion.php |
Updates backup exclusion rules for restore-info artifacts. |
admin/class-boldgrid-backup-admin-cron.php |
Uses hash_equals and proper unslashing for cron validation inputs. |
admin/class-boldgrid-backup-admin-core.php |
Adds secure CLI storage migration helper and runs it during admin bootstrap. |
admin/class-boldgrid-backup-admin-archive.php |
Ensures secure storage before writing restore results metadata. |
.travis.yml |
Modernizes CI setup and adds a PHP 8.5 job with custom install. |
.gitignore |
Ignores newly introduced local/secret artifacts and standard dev outputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Harden restore-locator direct-execution checks, migrate orphan cron restore-info files, fail closed on weak CSPRNG secret generation, and polyfill hash_equals for standalone env-info.
Parse MySQL 8 enumerated *.* grants so VIEW backups and import tests work on Focal, quiet PHP 8.5 deprecation log floods, restore PHPUnit 7 compatible phpunit.xml, and align the declared PHP minimum to 7.4.
CI follow-up (
|
Register the library version from Composer 2 installed.json before Load, and skip the library activation hook that would overwrite it with null.
Follow-up: Composer 2 library load (5f11848)After the Composer 2 CI/deps update, plugin activation could fatal with Cause: Composer 2 nests packages under Fix (plugin-side, no vendor edits):
Same pattern as Post and Page Builder. Verified activate is clean and |
is_resource() is false for FTP\Connection, so ftp_close() never ran and FTPS teardown logged OpenSSL unexpected EOF on request shutdown.
Pure-FTPd returns folder/file.zip from ftp_nlist, so basename matching failed and the details page kept showing Upload after a successful transfer.
Defer plugin bootstrap and translated strings until init so constructors no longer trigger _load_textdomain_just_in_time notices.
init_premium was still hooked to plugins_loaded, which has already fired when bootstrap runs on init for WP 6.7+ textdomain safety.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 33 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
admin/class-boldgrid-backup-admin.php:275
- Docblock uses
@since1.17.4, but this PR bumps the plugin to 1.17.3; the annotation should not reference a future version.
* @since 1.17.4
cli/class-info.php:504
- If persisting the newly generated secret fails, ensure_secure_storage() continues and later deletes legacy verify/restore-info files, leaving the install without any stable secret. Treat persist_secret() failure as a hard failure and bail out before continuing.
if ( empty( $secret ) ) {
// Fail closed; leave legacy files in place until a CSPRNG is available.
return null;
}
self::persist_secret( $secret, $storage_dir );
Correct @SInCE tags, broaden restore-info backup exclusion, use a proper 403 Forbidden reason phrase, and fail closed when secret persistence fails.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Legacy restore-info deleted on failed migrate
- Modified ensure_secure_storage() to only delete legacy cron/restore-info-*.json files after migrate_legacy_restore_info() returns true, preventing data loss on failed migration.
- ✅ Fixed: Weaker global grants hide DB ALL
- Refactored get_db_privileges() to collect all grants before returning, preferring DB-specific grants over global grants and returning ALL immediately for GRANT ALL PRIVILEGES on either scope.
- ✅ Fixed: Locator lost after plugin updates
- Modified write_restore_locator() to also write to wp-content/.boldgrid-backup-locator.php and get_dir_from_locator() to check the wp-content fallback when plugin tree locator is missing.
Or push these changes by commenting:
@cursor push 65e24fe9bb
Preview (65e24fe9bb)
diff --git a/admin/class-boldgrid-backup-admin-db-import.php b/admin/class-boldgrid-backup-admin-db-import.php
--- a/admin/class-boldgrid-backup-admin-db-import.php
+++ b/admin/class-boldgrid-backup-admin-db-import.php
@@ -358,6 +358,9 @@
/**
* Get database user privileges.
*
+ * Parses SHOW GRANTS results, preferring DB-specific grants over global ones.
+ * This handles common setups where a user has USAGE on *.* plus ALL on the site DB.
+ *
* @since 1.14.0
*
* @global wpdb $wpdb The WordPress database class object.
@@ -367,6 +370,9 @@
public function get_db_privileges() {
$results = $this->show_grants_query();
+ $db_grants = array();
+ $global_grants = array();
+
foreach ( $results as $result ) {
$result[0] = str_replace( '\\', '', $result[0] );
$is_string_db_grant = ( false !== strpos( $result[0], 'ON `' . DB_NAME . '`' ) );
@@ -377,22 +383,39 @@
continue;
}
- if ( $is_grant_all_privileges ) {
+ // DB-specific ALL is definitive.
+ if ( $is_string_db_grant && $is_grant_all_privileges ) {
return array( 'ALL' );
}
+ // Global ALL is definitive.
+ if ( $is_string_all_grant && $is_grant_all_privileges ) {
+ return array( 'ALL' );
+ }
+
/*
* MySQL 8 often enumerates privileges on *.* instead of "GRANT ALL PRIVILEGES".
- * Parse those lists too; skip the default USAGE-only row so later grants are seen.
+ * Parse those lists; skip the default USAGE-only row.
*/
$grants = $this->get_grants_array( $result[0] );
if ( array( 'USAGE' ) === $grants ) {
continue;
}
- return $grants;
+ // Collect grants, preferring DB-specific over global.
+ if ( $is_string_db_grant ) {
+ $db_grants = array_unique( array_merge( $db_grants, $grants ) );
+ } else {
+ $global_grants = array_unique( array_merge( $global_grants, $grants ) );
+ }
}
- return array();
+
+ // Prefer DB-specific grants; fall back to global.
+ if ( ! empty( $db_grants ) ) {
+ return $db_grants;
+ }
+
+ return $global_grants;
}
/**
diff --git a/cli/class-info.php b/cli/class-info.php
--- a/cli/class-info.php
+++ b/cli/class-info.php
@@ -65,6 +65,14 @@
const RESTORE_LOCATOR_FILE = 'restore-locator.php';
/**
+ * Filename of the restore locator stored in wp-content (survives plugin updates).
+ *
+ * @since 1.17.3
+ * @var string
+ */
+ const WP_CONTENT_LOCATOR_FILE = '.boldgrid-backup-locator.php';
+
+ /**
* Filename used to persist the per-install secret in the backup directory.
*
* @since 1.17.3
@@ -104,7 +112,7 @@
}
/**
- * Get the path to the restore locator file.
+ * Get the path to the restore locator file (plugin tree).
*
* @since 1.17.3
* @static
@@ -116,17 +124,53 @@
}
/**
- * Read the backup/storage directory from the restore locator.
+ * Get the path to the restore locator file in wp-content (survives plugin updates).
*
* @since 1.17.3
* @static
*
+ * @return string|null Path or null if wp-content cannot be determined.
+ */
+ public static function get_wp_content_locator_filepath() {
+ $wp_content = self::get_wp_content_dir();
+ if ( ! $wp_content ) {
+ return null;
+ }
+ return $wp_content . '/' . self::WP_CONTENT_LOCATOR_FILE;
+ }
+
+ /**
+ * Determine the wp-content directory from the plugin location.
+ *
+ * This works even when WordPress is not loaded, by walking up from cli/.
+ *
+ * @since 1.17.3
+ * @static
+ *
+ * @return string|null
+ */
+ public static function get_wp_content_dir() {
+ // cli/ -> boldgrid-backup/ -> plugins/ -> wp-content/
+ $wp_content = dirname( dirname( dirname( __DIR__ ) ) );
+ // Sanity check: the parent should be "plugins" or contain a "plugins" marker.
+ $plugins_dir = dirname( __DIR__ );
+ if ( basename( dirname( $plugins_dir ) ) === 'plugins' ) {
+ return $wp_content;
+ }
+ return null;
+ }
+
+ /**
+ * Read the backup/storage directory from a locator file.
+ *
+ * @since 1.17.3
+ * @static
+ *
+ * @param string $locator Path to locator file.
* @return string|null Absolute directory path, or null if unavailable.
*/
- public static function get_dir_from_locator() {
- $locator = self::get_restore_locator_filepath();
-
- if ( ! file_exists( $locator ) || ! is_readable( $locator ) ) {
+ private static function read_locator_file( $locator ) {
+ if ( ! $locator || ! file_exists( $locator ) || ! is_readable( $locator ) ) {
return null;
}
@@ -142,6 +186,27 @@
}
/**
+ * Read the backup/storage directory from the restore locator.
+ *
+ * Checks the plugin tree first, then falls back to wp-content (survives updates).
+ *
+ * @since 1.17.3
+ * @static
+ *
+ * @return string|null Absolute directory path, or null if unavailable.
+ */
+ public static function get_dir_from_locator() {
+ // Primary: plugin tree (fastest, but lost on plugin update).
+ $dir = self::read_locator_file( self::get_restore_locator_filepath() );
+ if ( $dir ) {
+ return $dir;
+ }
+
+ // Fallback: wp-content (survives plugin updates).
+ return self::read_locator_file( self::get_wp_content_locator_filepath() );
+ }
+
+ /**
* Trim trailing directory separators (WP-independent).
*
* @since 1.17.3
@@ -187,6 +252,7 @@
* Write the restore locator file used by CLI (and env-info) to find storage.
*
* The locator refuses direct HTTP execution; it only returns a path when included.
+ * Writes to both the plugin tree (fast lookup) and wp-content (survives updates).
*
* @since 1.17.3
* @static
@@ -215,8 +281,15 @@
'}' . "\n" .
'return ' . var_export( $storage_dir, true ) . ";\n"; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+ // Write to plugin tree (primary, fast lookup).
$written = ( false !== file_put_contents( self::get_restore_locator_filepath(), $contents ) );
+ // Also write to wp-content (survives plugin updates for CLI emergency restore).
+ $wp_content_locator = self::get_wp_content_locator_filepath();
+ if ( $wp_content_locator ) {
+ @file_put_contents( $wp_content_locator, $contents ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ }
+
if ( $written ) {
// Reset cached path so the next get_results_filepath() uses the locator.
self::$results_file_path = null;
@@ -511,10 +584,14 @@
self::$results_file_path = null;
// Migrate restore-info from cron/ before deleting legacy plugin-tree copies.
- self::migrate_legacy_restore_info( $storage_dir, $secret, $legacy_secret );
+ $migrated = self::migrate_legacy_restore_info( $storage_dir, $secret, $legacy_secret );
+ // Only delete legacy files after successful migration (or when no legacy files exist).
+ // This prevents data loss if migration fails due to disk full, permissions, etc.
self::delete_legacy_verify_files();
- self::delete_legacy_cron_restore_info_files();
+ if ( $migrated ) {
+ self::delete_legacy_cron_restore_info_files();
+ }
return $storage_dir;
}You can send follow-ups to the cloud agent here.
Keep legacy restore-info until migration succeeds, prefer DB-scoped grants over partial global lists, and persist a wp-content locator fallback so emergency CLI survives plugin updates.
Repair missing ZIP64 EOCD metadata after php_zip close, prefer ZipArchive when listing archives, and silence benign FTPS ftp_close SSL EOF warnings on Pure-FTPd.
- get_secret(): Return empty string when no storage directory exists instead of caching an in-memory secret that drifts across PHP requests - ensure_secure_storage(): When a new secret is generated but migration of legacy restore-info fails, remove the newly persisted secret and return null to prevent orphaning restore-info under the old secret filename
When ensure_secure_storage() generates a new secret and migrate_legacy_restore_info() returns false (indicating no legacy data to migrate), the code was incorrectly deleting the newly generated secret. This prevented emergency restore metadata from being written on fresh installs. Now the rollback logic checks whether legacy restore-info files actually exist before removing the new secret. On fresh installs with no cron/ or previous backup directory restore-info files, the new secret is preserved so that write_results_file() can proceed normally.
…clusion Bug fixes: 1. ZIP64 repair can corrupt archives (high): Backup original trailer bytes before writing ZIP64 EOCD and restore them if verification fails 2. Secret rollback skips storage orphans (medium): Check storage_dir for orphan restore-info files in addition to cron/ and previous_dir 3. Unused restore-info filename property (low): Remove dead property assignment and declaration from folder exclusion constructor 4. CLI secret mint skips remigration (high): Call migrate_legacy_restore_info() when generating new secret in get_secret() to handle orphan files 5. Locator left after migration rollback (high): Remove both plugin-tree and wp-content locators during rollback to prevent pointing at wrong directory
Once a per-install secret is persisted, remove webroot verify-*.php even when there is no restore-info to copy. Keep cron restore-info deletion gated on successful migration.
When ensure_secure_storage() calls write_restore_locator() and then fails at generate_secret() or persist_secret(), the locators were left pointing at a directory without a valid secret file. This could cause subsequent get_secret() calls to generate a new secret, diverging from any restore-info still named under the prior secret. This fix: 1. Adds remove_restore_locators() helper to clean up both locators 2. Calls this helper before early returns when secret generation fails 3. Calls this helper before early returns when secret persistence fails 4. Refactors the existing migration-rollback path to use the new helper This makes the cleanup behavior consistent across all failure paths.
…fails When migrating to a new backup directory, if the secret was copied from the previous directory (not newly generated), migration failures were not being detected and rolled back. This left restore-info-*.json files stranded in the previous directory while locators pointed to the new directory. Changes: - Track when secret is copied from previous directory via $copied_from_prev - Include copied secrets in the rollback check alongside generated secrets - Add rollback logic to get_secret() when migration fails with legacy data This ensures that when migration fails and there is data to migrate, the system rolls back to prevent stranding restore-info files.
Bug fixes: 1. ZIP repair skipped on successful open: Call maybe_repair_zip64_eocd() in get_file_via_zip_archive() before opening the archive, ensuring file extraction works on broken large archives. 2. Failed ZIP write leaves archive damaged: Restore original trailer bytes when fwrite() fails in write_zip64_eocd(), preventing partial overwrites from corrupting the archive. 3. Dir-change rollback drops locators: On failure during backup directory change, restore locators to previous directory instead of deleting them entirely, so CLI can still find the old directory's metadata. 4. Stale restore-info after failed migrate: Return failure from ensure_secure_storage() when migration fails with legacy data present, even if secret already existed, so callers know restore-info is incomplete.
Repair ZIP64 EOCD when open succeeds with an empty listing, reject short trailer writes without verification, and exclude restore locator files from site backups.
When ensure_secure_storage() encounters a migration failure and there is a pre-existing secret in the target directory (not newly generated or copied), the function was returning null without restoring the locators to their previous state. This left locators pointing at the new directory while the restore-info file remained only in cron/ or the old backup dir. The fix adds an else branch to also restore locators (and reset the cached secret) when migration fails with a pre-existing secret, ensuring CLI resolution can find the restore-info at the old path and preventing write_results_file() from being stuck in a failing state.
Bug 1: ZIP64 repair verification too weak - The write_zip64_eocd() verification step now checks both that ZipArchive::open() succeeds AND that numFiles > 0, matching the same broken-archive detection logic used in open_zip_archive(). - This prevents accepting an archive that opens but reports zero entries as successfully repaired, which would skip future repair attempts due to the ZIP64 locator presence check. Bug 2: Failed results path stays cached - get_results_filepath() now re-evaluates when the cached value is an empty string, not just when it's null. Previously, caching '' on secret validation failure would prevent retry after a later successful persist_secret() call reset the cache to null and made a valid secret available.
- Fix unreadable legacy info blocks causing rollback loop: Check readability in addition to presence when detecting legacy restore-info files that should be migrated, preventing infinite migration retry when files exist but are unreadable due to permission issues. - Fix local cron_intervals being overwritten: Translate known default interval labels in place rather than replacing the entire array, preserving custom intervals from config.local.php. - Fix zip folders misclassified: Use ZipArchive's getExternalAttributesIndex() to check the directory bit (0x10) in addition to trailing slash, matching the behavior of the central-directory walker. - Fix ZIP64 repair false empty success: Require cd_size=0 when both entry counts are 0, distinguishing truly empty archives from broken EOCD where cd_offset prevents enumeration.
Fail closed when ZIP64 repair cannot confirm a usable archive, restore prior locators on secret setup failure, reuse ZIP repair in emergency CLI restore, and strip leftover verify/cron artifacts after get_secret migration.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Init deferral breaks localization timing
- Added
&& ! doing_action( 'init' )check to all three locations to ensure localization defers to the init hook when already inside init priority 1, allowing load_plugin_textdomain to run first.
- Added
- ✅ Fixed: Writability gate fails valid ZIPs
- Moved is_writable() check to only run after determining a repair is actually needed, allowing valid read-only archives to pass without requiring writability.
- ✅ Fixed: Mismatched EOCD counts skipped
- Simplified the validation logic to check if counts match and no ZIP64 is needed for validity, ensuring all mismatched counts (including non-zero classic EOCD counts) proceed to repair.
Preview (61b19eaf27)
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,25 @@
-config.php
-error_log
*.swp
-logs*
.buildpath
+.cursor/working/
+.ea-php-cli.cache
+.phpunit.result.cache
.project
.settings
-node_modules
+.vscode
+boldgrid-backup-cron.log
bower_components
build/
-vendor/
-reports/
-package-lock.json
-yarn-error.log
cli/bgbkup-cli.log
cli/verify-*
+cli/restore-locator.php
+config.php
cron/cron-test.config
cron/cron-test.result
cron/restore-info-*
-boldgrid-backup-cron.log
-.ea-php-cli.cache
-.vscode
+error_log
+logs*
+node_modules
+package-lock.json
reports/
+vendor/
+yarn-error.log
@@ -1,23 +1,25 @@
-config.php
-error_log
*.swp
-logs*
.buildpath
+.cursor/working/
+.ea-php-cli.cache
+.phpunit.result.cache
.project
.settings
-node_modules
+.vscode
+boldgrid-backup-cron.log
bower_components
build/
-vendor/
-reports/
-package-lock.json
-yarn-error.log
cli/bgbkup-cli.log
cli/verify-*
+cli/restore-locator.php
+config.php
cron/cron-test.config
cron/cron-test.result
cron/restore-info-*
-boldgrid-backup-cron.log
-.ea-php-cli.cache
-.vscode
+error_log
+logs*
+node_modules
+package-lock.json
reports/
+vendor/
+yarn-error.log
diff --git a/.travis.yml b/.travis.yml
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,3 +1,9 @@
+# Travis CI configuration for the Total Upkeep (boldgrid-backup) WordPress plugin by BoldGrid.
+# @link https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/#running-tests-on-travis-ci
+# @link https://docs.travis-ci.com/user/job-lifecycle
+#
+# Dist ZIP is built on PHP 7.4 for compatibility with PHP 7.4 and up.
+
language: php
services:
@@ -1,3 +1,9 @@
+# Travis CI configuration for the Total Upkeep (boldgrid-backup) WordPress plugin by BoldGrid.
+# @link https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/#running-tests-on-travis-ci
+# @link https://docs.travis-ci.com/user/job-lifecycle
+#
+# Dist ZIP is built on PHP 7.4 for compatibility with PHP 7.4 and up.
+
language: php
services:
@@ -12,40 +18,65 @@ matrix:
include:
-
php: 7.4
- dist: bionic
- before_script:
- - nvm install 10
- - nvm use 10
- - yarn install
- - composer self-update --1
- - composer remove --dev phpunit/phpunit && composer require --dev phpunit/phpunit
- - composer install -o
- - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
+ dist: focal
+ before_install:
+ - nvm install 20
+ - nvm use 20
+ before_deploy:
+ # Fix Ruby Gems for GitHub Releases deployment.
+ - yes | gem update --system --force
+ - gem install bundler
+ - gem install faraday-net_http -v '3.3.0'
+ - gem install uri
+ - gem install logger
+
+ # Travis's phpenv does not currently provide a PHP 8.5 build (jammy tops out
+ # at 8.4 as of 2026), so we bypass Travis's PHP language setup with
+ # `language: ruby` and install PHP 8.5 from the ondrej/php PPA. Side-effect:
+ # TRAVIS_PHP_VERSION is unset for this job, which is exactly what we want --
+ # the release-deploy gates below key off "7.4" and naturally skip this job.
+ # @link https://github.com/BoldGrid/w3-total-cache/blob/master/.travis.yml
-
- php: 8.0
- dist: xenial
- before_script:
- - nvm install 10
- - nvm use 10
- - yarn install
- - composer self-update --1
- - composer remove --dev phpunit/phpunit && composer require --dev phpunit/phpunit
- - composer install -o
- - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
+ name: "PHP: 8.5"
+ language: ruby
+ dist: jammy
+ before_install:
+ - nvm install 20
+ - nvm use 20
+ - sudo add-apt-repository -y ppa:ondrej/php
+ - sudo apt-get update -q
+ - sudo apt-get install -y php8.5-cli php8.5-common php8.5-mbstring php8.5-xml php8.5-mysql php8.5-curl php8.5-zip
+ - sudo update-alternatives --set php /usr/bin/php8.5
+ - php -v
+ # `language: ruby` skips Travis's phpenv composer shim; install composer ourselves.
+ - curl -sS https://getcomposer.org/installer | php
+ - sudo mv composer.phar /usr/local/bin/composer
+ - composer --version
+
+install:
+ - composer self-update
+ - yarn install
+ - composer install --no-interaction --prefer-dist -o
+ # Lock targets PHPUnit 7 (platform.php 7.4). On PHP 8.5, install a runtime-compatible PHPUnit for tests only.
+ - |
+ if [[ "${TRAVIS_PHP_VERSION:0:3}" != "7.4" ]]; then
+ composer config --unset platform.php
+ composer require --dev 'phpunit/phpunit:^9.6.33' --no-interaction --with-all-dependencies -W
+ fi
+ - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
script:
- find . \( -name composer -o -name node_modules -o -name phpunit -o -name phpunit-polyfills -o -name code-unit-reverse-lookup \) -prune -o -name '*.php' -exec php -lf {} \; > /dev/null
- - vendor/phpunit/phpunit/phpunit --debug --verbose
+ # PHP 8.5: avoid --debug/--verbose (deprecation traces dump huge TestCase graphs and exceed Travis log limits).
+ - |
+ if [[ "${TRAVIS_PHP_VERSION:0:3}" == "7.4" ]]; then
+ vendor/phpunit/phpunit/phpunit --debug --verbose
+ else
+ php -d display_errors=1 -d error_reporting=22527 vendor/phpunit/phpunit/phpunit
+ fi
- yarn run js-lint
- # Remove dev scripts.
- - composer install -o --no-dev
-
-before_deploy:
- - yes | gem update --system --force
- - gem install bundler
- - gem install faraday-net_http -v '3.3.0'
- - gem install uri
- - gem install logger
+ # Remove dev dependencies before packaging / final lint.
+ - composer install --no-dev --no-interaction --prefer-dist -o
deploy:
- provider: script
@@ -12,40 +18,65 @@ matrix:
include:
-
php: 7.4
- dist: bionic
- before_script:
- - nvm install 10
- - nvm use 10
- - yarn install
- - composer self-update --1
- - composer remove --dev phpunit/phpunit && composer require --dev phpunit/phpunit
- - composer install -o
- - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
+ dist: focal
+ before_install:
+ - nvm install 20
+ - nvm use 20
+ before_deploy:
+ # Fix Ruby Gems for GitHub Releases deployment.
+ - yes | gem update --system --force
+ - gem install bundler
+ - gem install faraday-net_http -v '3.3.0'
+ - gem install uri
+ - gem install logger
+
+ # Travis's phpenv does not currently provide a PHP 8.5 build (jammy tops out
+ # at 8.4 as of 2026), so we bypass Travis's PHP language setup with
+ # `language: ruby` and install PHP 8.5 from the ondrej/php PPA. Side-effect:
+ # TRAVIS_PHP_VERSION is unset for this job, which is exactly what we want --
+ # the release-deploy gates below key off "7.4" and naturally skip this job.
+ # @link https://github.com/BoldGrid/w3-total-cache/blob/master/.travis.yml
-
- php: 8.0
- dist: xenial
- before_script:
- - nvm install 10
- - nvm use 10
- - yarn install
- - composer self-update --1
- - composer remove --dev phpunit/phpunit && composer require --dev phpunit/phpunit
- - composer install -o
- - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
+ name: "PHP: 8.5"
+ language: ruby
+ dist: jammy
+ before_install:
+ - nvm install 20
+ - nvm use 20
+ - sudo add-apt-repository -y ppa:ondrej/php
+ - sudo apt-get update -q
+ - sudo apt-get install -y php8.5-cli php8.5-common php8.5-mbstring php8.5-xml php8.5-mysql php8.5-curl php8.5-zip
+ - sudo update-alternatives --set php /usr/bin/php8.5
+ - php -v
+ # `language: ruby` skips Travis's phpenv composer shim; install composer ourselves.
+ - curl -sS https://getcomposer.org/installer | php
+ - sudo mv composer.phar /usr/local/bin/composer
+ - composer --version
+
+install:
+ - composer self-update
+ - yarn install
+ - composer install --no-interaction --prefer-dist -o
+ # Lock targets PHPUnit 7 (platform.php 7.4). On PHP 8.5, install a runtime-compatible PHPUnit for tests only.
+ - |
+ if [[ "${TRAVIS_PHP_VERSION:0:3}" != "7.4" ]]; then
+ composer config --unset platform.php
+ composer require --dev 'phpunit/phpunit:^9.6.33' --no-interaction --with-all-dependencies -W
+ fi
+ - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
script:
- find . \( -name composer -o -name node_modules -o -name phpunit -o -name phpunit-polyfills -o -name code-unit-reverse-lookup \) -prune -o -name '*.php' -exec php -lf {} \; > /dev/null
- - vendor/phpunit/phpunit/phpunit --debug --verbose
+ # PHP 8.5: avoid --debug/--verbose (deprecation traces dump huge TestCase graphs and exceed Travis log limits).
+ - |
+ if [[ "${TRAVIS_PHP_VERSION:0:3}" == "7.4" ]]; then
+ vendor/phpunit/phpunit/phpunit --debug --verbose
+ else
+ php -d display_errors=1 -d error_reporting=22527 vendor/phpunit/phpunit/phpunit
+ fi
- yarn run js-lint
- # Remove dev scripts.
- - composer install -o --no-dev
-
-before_deploy:
- - yes | gem update --system --force
- - gem install bundler
- - gem install faraday-net_http -v '3.3.0'
- - gem install uri
- - gem install logger
+ # Remove dev dependencies before packaging / final lint.
+ - composer install --no-dev --no-interaction --prefer-dist -o
deploy:
- provider: script
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ WordPress backup and restoration with update protection.
### Minimum Requirements ###
-* PHP 5.4 or higher
+* PHP 7.4 or higher
* At least one of the following PHP execution functions enabled: "popen", "proc_open", "exec", "shell_exec", "passthru", or "system".
* A Cron system with the "crontab" utility, or WP Cron.
* A WP_Filesystem FS_METHOD being "direct".
@@ -13,7 +13,7 @@ WordPress backup and restoration with update protection.
### Minimum Requirements ###
-* PHP 5.4 or higher
+* PHP 7.4 or higher
* At least one of the following PHP execution functions enabled: "popen", "proc_open", "exec", "shell_exec", "passthru", or "system".
* A Cron system with the "crontab" utility, or WP Cron.
* A WP_Filesystem FS_METHOD being "direct".
diff --git a/admin/class-boldgrid-backup-admin-archive.php b/admin/class-boldgrid-backup-admin-archive.php
--- a/admin/class-boldgrid-backup-admin-archive.php
+++ b/admin/class-boldgrid-backup-admin-archive.php
@@ -697,8 +697,26 @@ public function generate_download_link( $filename ) {
public function write_results_file( $info ) {
$success = false;
$archive_filepath = ! empty( $info['filepath'] ) ? $info['filepath'] : null;
- $results_filepath = \Boldgrid\Backup\Cli\Info::get_results_filepath();
- $is_dir_writable = $this->core->wp_filesystem->is_writable( dirname( $results_filepath ) );
+ $backup_dir = $this->core->backup_dir->get();
+
+ if ( empty( $backup_dir ) ) {
+ return false;
+ }
+
+ // Require secure storage; do not fall back to writing restore-info under cron/.
+ $backup_dir = \Boldgrid\Backup\Cli\Info::untrailingslashit_path( (string) $backup_dir );
+ if ( ! \Boldgrid\Backup\Cli\Info::ensure_secure_storage( $backup_dir ) ) {
+ return false;
+ }
+
+ $secret = \Boldgrid\Backup\Cli\Info::get_secret();
+ if ( ! \Boldgrid\Backup\Cli\Info::is_valid_secret_format( $secret ) ) {
+ return false;
+ }
+
+ // Always write into the known backup directory — never the legacy cron/ fallback.
+ $results_filepath = \Boldgrid\Backup\Cli\Info::trailingslashit_path( $backup_dir ) . 'restore-info-' . $secret . '.json';
+ $is_dir_writable = \Boldgrid\Backup\Cli\Info::is_directory_writable( $backup_dir );
if ( $archive_filepath && $is_dir_writable ) {
$results_filepath = wp_normalize_path( $results_filepath );
@@ -697,8 +697,26 @@ public function generate_download_link( $filename ) {
public function write_results_file( $info ) {
$success = false;
$archive_filepath = ! empty( $info['filepath'] ) ? $info['filepath'] : null;
- $results_filepath = \Boldgrid\Backup\Cli\Info::get_results_filepath();
- $is_dir_writable = $this->core->wp_filesystem->is_writable( dirname( $results_filepath ) );
+ $backup_dir = $this->core->backup_dir->get();
+
+ if ( empty( $backup_dir ) ) {
+ return false;
+ }
+
+ // Require secure storage; do not fall back to writing restore-info under cron/.
+ $backup_dir = \Boldgrid\Backup\Cli\Info::untrailingslashit_path( (string) $backup_dir );
+ if ( ! \Boldgrid\Backup\Cli\Info::ensure_secure_storage( $backup_dir ) ) {
+ return false;
+ }
+
+ $secret = \Boldgrid\Backup\Cli\Info::get_secret();
+ if ( ! \Boldgrid\Backup\Cli\Info::is_valid_secret_format( $secret ) ) {
+ return false;
+ }
+
+ // Always write into the known backup directory — never the legacy cron/ fallback.
+ $results_filepath = \Boldgrid\Backup\Cli\Info::trailingslashit_path( $backup_dir ) . 'restore-info-' . $secret . '.json';
+ $is_dir_writable = \Boldgrid\Backup\Cli\Info::is_directory_writable( $backup_dir );
if ( $archive_filepath && $is_dir_writable ) {
$results_filepath = wp_normalize_path( $results_filepath );
@@ -736,11 +754,25 @@ public function write_results_file( $info ) {
'timestamp' => time(),
);
- $success = $this->core->wp_filesystem->put_contents(
+ $payload = wp_json_encode( $results );
+ $success = (bool) $this->core->wp_filesystem->put_contents(
$results_filepath,
- wp_json_encode( $results ),
+ $payload,
0600
);
+
+ /*
+ * If WP_Filesystem cannot write but the shared writability probe could,
+ * fall back to a direct write (same approach Info uses for secrets /
+ * migrated restore-info).
+ */
+ if ( ! $success ) {
+ $written = @file_put_contents( $results_filepath, $payload ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ if ( false !== $written ) {
+ @chmod( $results_filepath, 0600 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ $success = true;
+ }
+ }
}
return $success;
@@ -736,11 +754,25 @@ public function write_results_file( $info ) {
'timestamp' => time(),
);
- $success = $this->core->wp_filesystem->put_contents(
+ $payload = wp_json_encode( $results );
+ $success = (bool) $this->core->wp_filesystem->put_contents(
$results_filepath,
- wp_json_encode( $results ),
+ $payload,
0600
);
+
+ /*
+ * If WP_Filesystem cannot write but the shared writability probe could,
+ * fall back to a direct write (same approach Info uses for secrets /
+ * migrated restore-info).
+ */
+ if ( ! $success ) {
+ $written = @file_put_contents( $results_filepath, $payload ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ if ( false !== $written ) {
+ @chmod( $results_filepath, 0600 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ $success = true;
+ }
+ }
}
return $success;
diff --git a/admin/class-boldgrid-backup-admin-config.php b/admin/class-boldgrid-backup-admin-config.php
--- a/admin/class-boldgrid-backup-admin-config.php
+++ b/admin/class-boldgrid-backup-admin-config.php
@@ -139,7 +139,17 @@ public function __construct( $core ) {
$this->is_premium = $this->license->isPremium( 'boldgrid-backup' );
}
- $this->set_lang();
+ /*
+ * Defer translated strings until init (WP 6.7+).
+ *
+ * Calling esc_html__() during plugin bootstrap triggers
+ * _load_textdomain_just_in_time too early.
+ */
+ if ( did_action( 'init' ) ) {
+ $this->set_lang();
+ } else {
+ add_action( 'init', array( $this, 'set_lang' ) );
+ }
}
/**
@@ -139,7 +139,17 @@ public function __construct( $core ) {
$this->is_premium = $this->license->isPremium( 'boldgrid-backup' );
}
- $this->set_lang();
+ /*
+ * Defer translated strings until init (WP 6.7+).
+ *
+ * Calling esc_html__() during plugin bootstrap triggers
+ * _load_textdomain_just_in_time too early.
+ */
+ if ( did_action( 'init' ) ) {
+ $this->set_lang();
+ } else {
+ add_action( 'init', array( $this, 'set_lang' ) );
+ }
}
/**
diff --git a/admin/class-boldgrid-backup-admin-core.php b/admin/class-boldgrid-backup-admin-core.php
--- a/admin/class-boldgrid-backup-admin-core.php
+++ b/admin/class-boldgrid-backup-admin-core.php
@@ -687,8 +687,8 @@ public function __construct() {
$this->pagenow = $pagenow;
- // Instantiate Configs Array
- $this->configs = Boldgrid_Backup_Admin::get_configs();
+ // Instantiate Configs Array (by reference so init localization applies).
+ $this->configs =& Boldgrid_Backup_Admin::get_configs_ref();
// Instantiate Boldgrid_Backup_Admin_Settings.
$this->settings = new Boldgrid_Backup_Admin_Settings( $this );
@@ -687,8 +687,8 @@ public function __construct() {
$this->pagenow = $pagenow;
- // Instantiate Configs Array
- $this->configs = Boldgrid_Backup_Admin::get_configs();
+ // Instantiate Configs Array (by reference so init localization applies).
+ $this->configs =& Boldgrid_Backup_Admin::get_configs_ref();
// Instantiate Boldgrid_Backup_Admin_Settings.
$this->settings = new Boldgrid_Backup_Admin_Settings( $this );
@@ -810,7 +810,17 @@ public function __construct() {
// Ensure there is a backup identifier.
$this->get_backup_identifier();
- $this->set_lang();
+ /*
+ * Defer translated strings until init (WP 6.7+).
+ *
+ * Calling esc_html__() during plugin bootstrap triggers
+ * _load_textdomain_just_in_time too early.
+ */
+ if ( did_action( 'init' ) && ! doing_action( 'init' ) ) {
+ $this->set_lang();
+ } else {
+ add_action( 'init', array( $this, 'set_lang' ) );
+ }
// Log system.
$this->logger = new Boldgrid_Backup_Admin_Log( $this );
@@ -810,7 +810,17 @@ public function __construct() {
// Ensure there is a backup identifier.
$this->get_backup_identifier();
- $this->set_lang();
+ /*
+ * Defer translated strings until init (WP 6.7+).
+ *
+ * Calling esc_html__() during plugin bootstrap triggers
+ * _load_textdomain_just_in_time too early.
+ */
+ if ( did_action( 'init' ) && ! doing_action( 'init' ) ) {
+ $this->set_lang();
+ } else {
+ add_action( 'init', array( $this, 'set_lang' ) );
+ }
// Log system.
$this->logger = new Boldgrid_Backup_Admin_Log( $this );
@@ -832,6 +842,26 @@ public function __construct() {
// Instantiate the new Boldgrid_Backup_Admin_Migrate class.
$this->migrate = new Boldgrid_Backup_Admin_Migrate( $this );
+
+ // Migrate CLI secret / restore-info out of the web-served plugin directory when possible.
+ $this->ensure_secure_cli_storage();
+ }
+
+ /**
+ * Ensure CLI secret and restore-info are stored outside the web-served plugin directory.
+ *
+ * @since 1.17.3
+ *
+ * @see \Boldgrid\Backup\Cli\Info::ensure_secure_storage()
+ */
+ public function ensure_secure_cli_storage() {
+ $backup_dir = $this->backup_dir->get();
+ if ( empty( $backup_dir ) ) {
+ return;
+ }
+
+ require_once BOLDGRID_BACKUP_PATH . '/cli/class-info.php';
+ \Boldgrid\Backup\Cli\Info::ensure_secure_storage( $backup_dir );
}
/**
@@ -832,6 +842,26 @@ public function __construct() {
// Instantiate the new Boldgrid_Backup_Admin_Migrate class.
$this->migrate = new Boldgrid_Backup_Admin_Migrate( $this );
+
+ // Migrate CLI secret / restore-info out of the web-served plugin directory when possible.
+ $this->ensure_secure_cli_storage();
+ }
+
+ /**
+ * Ensure CLI secret and restore-info are stored outside the web-served plugin directory.
+ *
+ * @since 1.17.3
+ *
+ * @see \Boldgrid\Backup\Cli\Info::ensure_secure_storage()
+ */
+ public function ensure_secure_cli_storage() {
+ $backup_dir = $this->backup_dir->get();
+ if ( empty( $backup_dir ) ) {
+ return;
+ }
+
+ require_once BOLDGRID_BACKUP_PATH . '/cli/class-info.php';
+ \Boldgrid\Backup\Cli\Info::ensure_secure_storage( $backup_dir );
}
/**
@@ -2015,6 +2045,29 @@ public function archive_files( $save = false, $dryrun = false ) {
$info['db_duration'] = number_format( ( $db_time_stop - $time_start ), 2, '.', '' );
$info['db_filename'] = basename( $this->db_dump_filepath );
+ // If not a dry-run test, update the last backup option and enforce retention.
+ if ( ! $dryrun ) {
+ // Update WP option for "boldgrid_backup_last_backup".
+ update_site_option( 'boldgrid_backup_last_backup', time() );
+
+ // Enforce retention setting.
+ $this->enforce_retention();
+ }
+
+ // Actions to take if we're creating a full site backup.
+ $restore_info_error = '';
+ if ( ! $dryrun && $this->archiver_utility->is_full_backup() ) {
+ $restore_info_written = $this->archive->write_results_file( $info );
+ if ( ! $restore_info_written ) {
+ $restore_info_error = __(
+ 'Backup archive created, but failed to write restore-info. Emergency CLI restore may not work until a successful write.',
+ 'boldgrid-backup'
+ );
+ $info['restore_info_error'] = $restore_info_error;
+ $this->logger->add( 'Warning: ' . $restore_info_error );
+ }
+ }
+
/**
* Actions to take after a backup has been created.
*
@@ -2015,6 +2045,29 @@ public function archive_files( $save = false, $dryrun = false ) {
$info['db_duration'] = number_format( ( $db_time_stop - $time_start ), 2, '.', '' );
$info['db_filename'] = basename( $this->db_dump_filepath );
+ // If not a dry-run test, update the last backup option and enforce retention.
+ if ( ! $dryrun ) {
+ // Update WP option for "boldgrid_backup_last_backup".
+ update_site_option( 'boldgrid_backup_last_backup', time() );
+
+ // Enforce retention setting.
+ $this->enforce_retention();
+ }
+
+ // Actions to take if we're creating a full site backup.
+ $restore_info_error = '';
+ if ( ! $dryrun && $this->archiver_utility->is_full_backup() ) {
+ $restore_info_written = $this->archive->write_results_file( $info );
+ if ( ! $restore_info_written ) {
+ $restore_info_error = __(
+ 'Backup archive created, but failed to write restore-info. Emergency CLI restore may not work until a successful write.',
+ 'boldgrid-backup'
+ );
+ $info['restore_info_error'] = $restore_info_error;
+ $this->logger->add( 'Warning: ' . $restore_info_error );
+ }
+ }
+
/**
* Actions to take after a backup has been created.
*
@@ -2044,6 +2097,7 @@ public function archive_files( $save = false, $dryrun = false ) {
* @type int $duration 57.08
* @type int $db_duration 0.35
* @type bool $mail_success
+ * @type string $restore_info_error (optional) Error message if restore-info failed to write.
* }
*/
do_action( 'boldgrid_backup_post_archive_files', $info );
@@ -2044,6 +2097,7 @@ public function archive_files( $save = false, $dryrun = false ) {
* @type int $duration 57.08
* @type int $db_duration 0.35
* @type bool $mail_success
+ * @type string $restore_info_error (optional) Error message if restore-info failed to write.
* }
*/
do_action( 'boldgrid_backup_post_archive_files', $info );
@@ -2068,36 +2122,39 @@ public function archive_files( $save = false, $dryrun = false ) {
$this->logger->add( 'Sending of email complete! Status: ' . $info['mail_success'] );
}
- // If not a dry-run test, update the last backup option and enforce retention.
+ /*
+ * Persist latest backup after restore-info and email so the option matches the returned
+ * $info (including restore_info_error and mail_success). Writing earlier caused
+ * Test_Boldgrid_Backup_Admin_Core::test_archive_files to fail strict equality.
+ */
if ( ! $dryrun ) {
- // Update WP option for "boldgrid_backup_last_backup".
- update_site_option( 'boldgrid_backup_last_backup', time() );
-
$this->archive_log->write( $info );
-
- // Enforce retention setting.
- $this->enforce_retention();
-
update_option( 'boldgrid_backup_latest_backup', $info );
}
- // Actions to take if we're creating a full site backup.
- if ( ! $dryrun && $this->archiver_utility->is_full_backup() ) {
- $this->archive->write_results_file( $info );
- }
-
if ( isset( $this->activity ) ) {
$this->activity->add( 'any_backup_created', 1, $this->rating_prompt_config );
}
- $this->logger->add( 'Backup complete!' );
+ if ( $restore_info_error ) {
+ $this->logger->add( 'Backup finished with restore-info errors.' );
+ } else {
+ $this->logger->add( 'Backup complete!' );
+ }
$this->logger->add_memory();
$this->archiving_files = false;
+ /*
+ * Archive itself succeeded; surface restore-info write failures in status so
+ * manual backups are not reported as fully healthy when emergency metadata is missing.
+ */
Boldgrid_Backup_Admin_In_Progress_Data::set_args( array(
- 'status' => esc_html__( 'Backup complete!', 'boldgrid-backup' ),
- 'success' => true,
+ 'status' => $restore_info_error
+ ? $restore_info_error
+ : esc_html__( 'Backup complete!', 'boldgrid-backup' ),
+ 'success' => empty( $restore_info_error ),
+ 'process_error' => $restore_info_error ? $restore_info_error : null,
) );
// Return the array of archive information.
@@ -2068,36 +2122,39 @@ public function archive_files( $save = false, $dryrun = false ) {
$this->logger->add( 'Sending of email complete! Status: ' . $info['mail_success'] );
}
- // If not a dry-run test, update the last backup option and enforce retention.
+ /*
+ * Persist latest backup after restore-info and email so the option matches the returned
+ * $info (including restore_info_error and mail_success). Writing earlier caused
+ * Test_Boldgrid_Backup_Admin_Core::test_archive_files to fail strict equality.
+ */
if ( ! $dryrun ) {
- // Update WP option for "boldgrid_backup_last_backup".
- update_site_option( 'boldgrid_backup_last_backup', time() );
-
$this->archive_log->write( $info );
-
- // Enforce retention setting.
- $this->enforce_retention();
-
update_option( 'boldgrid_backup_latest_backup', $info );
}
- // Actions to take if we're creating a full site backup.
- if ( ! $dryrun && $this->archiver_utility->is_full_backup() ) {
- $this->archive->write_results_file( $info );
- }
-
if ( isset( $this->activity ) ) {
$this->activity->add( 'any_backup_created', 1, $this->rating_prompt_config );
}
- $this->logger->add( 'Backup complete!' );
+ if ( $restore_info_error ) {
+ $this->logger->add( 'Backup finished with restore-info errors.' );
+ } else {
+ $this->logger->add( 'Backup complete!' );
+ }
$this->logger->add_memory();
$this->archiving_files = false;
+ /*
+ * Archive itself succeeded; surface restore-info write failures in status so
+ * manual backups are not reported as fully healthy when emergency metadata is missing.
+ */
Boldgrid_Backup_Admin_In_Progress_Data::set_args( array(
- 'status' => esc_html__( 'Backup complete!', 'boldgrid-backup' ),
- 'success' => true,
+ 'status' => $restore_info_error
+ ? $restore_info_error
+ : esc_html__( 'Backup complete!', 'boldgrid-backup' ),
+ 'success' => empty( $restore_info_error ),
+ 'process_error' => $restore_info_error ? $restore_info_error : null,
) );
// Return the array of archive information.
@@ -2781,14 +2838,20 @@ public function boldgrid_backup_now_callback() {
* If there were any errors encountered during the backup, save them to the In Progress data.
*
* A "process error" is when the archive_files() method successfully returns info, and it includes
- * an error.
+ * an error — or when it returns false.
*/
- if ( ! empty( $archive_info['error'] ) ) {
... diff truncated: showing 800 of 11321 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit f25d65c. Configure here.
- Fix init deferral breaking localization timing by adding doing_action('init')
check alongside did_action('init') in three places (boldgrid-backup.php,
class-boldgrid-backup-admin.php, class-boldgrid-backup-admin-core.php).
This ensures localize_configs() and set_lang() properly defer to init hook
when running inside init priority 1, allowing load_plugin_textdomain to run
first at default priority 10.
- Fix writability gate failing valid ZIPs by moving is_writable() check to only
run when an actual repair is needed. Previously, read-only but valid archives
would fail the backup even though no repair was required.
- Fix mismatched EOCD counts being skipped by simplifying the logic to check if
counts match and no ZIP64 is needed for validity, then attempting repair for
all other cases. This ensures non-zero but incorrect classic EOCD counts get
properly repaired.
Status update (2026-07-24)HEAD is Checks
Since last human approvalJacob’s earlier approvals were dismissed by follow-up commits. Notable later work (high level):
Ready for re-reviewPlease take another look when convenient — CI is green and the Bugbot/autofix loop has been frozen unless new medium+ issues appear. Optional low follow-ups (non-blocking): unused Internal tracking: ENG7-4430 (Dev ENG7-4431 / Review ENG7-4432). |


Summary
cli/verify-*files that could be included in release packages; strip them inbin/release.sh.cron//cli/, and use timing-safe comparisons for cron restore validation.phpunit.xml, align PHP minimum to 7.4.Compatibility
Mostly backwards-compatible for upgrades from 1.17.2 and earlier:
cron_secret/ backup id, crontab jobs, and admin UI restore continue to work unchanged.cron/restore-info.jsonis still renamed, then moved with the same migration.Caveats:
cron//cli/trees; after 1.17.3, once rewritten into the backup directory, that metadata survives later plugin updates.cron/restore-info-*on the next backup.Test plan
WP_TESTS_DIR=/home/$USER/wordpress-tests-lib/ XDEBUG_MODE=off ~/.config/composer/vendor/bin/phpunit --no-coverage --filter 'Test_Boldgrid_Backup_Admin_Db_Import|Test_Boldgrid_Backup_Admin_Migrate_Util|Test_Boldgrid_Backup_Cli_Info|Test_Boldgrid_Backup_Admin_Cron'(expected: 38 tests, 82 assertions, OK)cli/verify-*.phporcron/restore-info-*.jsonremain under the plugin directory.boldgrid-cli-secretandrestore-info-*.jsonafter a backup.../cron/restore-info-.jsonunder the plugin URL returns 404 (file not present in web tree)update_pluginsNote
High Risk
Changes authentication-adjacent cron secrets, emergency CLI restore paths, and backup success semantics; mis-migration or restore-info write failures could block emergency restore until a successful backup.
Overview
Total Upkeep 1.17.3 hardens emergency restore and broadens platform support.
Emergency restore metadata no longer lives under web-served
cron//cli/verify-*. Per-install secrets andrestore-info-*.jsonare stored in the backup directory (.boldgrid-cli-secret), with restore locators in the plugin tree andwp-content, migration on activation/admin load, and fail-closed behavior when secure storage or restore-info writes fail. Backups can surface warnings (UI, email, cron tasks) instead of appearing fully healthy when restore-info is missing. Cron URL validation useshash_equals, sensitive paths are excluded from archives and stripped from release zips, and Apache.htaccessblocks direct access where applicable.A new
Boldgrid_Backup_Admin_Ziplayer repairs ZIP64 EOCD issues on very large archives so browse/restore works via ZipArchive, PclZip, and CLI.WordPress 6.7+: plugin bootstrap and translations move to
init; config strings are localized after load. PHP minimum is 7.4; BoldGrid Library registration is fixed for Composer 2installed.json.CI targets PHP 7.4 (release) and PHP 8.5 (custom install), with adjusted PHPUnit on newer PHP. Smaller fixes: MySQL 8
SHOW GRANTSprivilege detection, FTPS disconnect on PHP 8FTP\Connection, Pure-FTPd listing basename matching.Reviewed by Cursor Bugbot for commit 61b19ea. Bugbot is set up for automated code reviews on this repo. Configure here.