Skip to content

ENG7-4430: Harden emergency restore metadata storage#634

Open
cssjoe wants to merge 53 commits into
masterfrom
ENG7-4430-cron-sec
Open

ENG7-4430: Harden emergency restore metadata storage#634
cssjoe wants to merge 53 commits into
masterfrom
ENG7-4430-cron-sec

Conversation

@cssjoe

@cssjoe cssjoe commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Move emergency restore metadata and the per-install CLI secret out of web-served plugin paths into the backup directory.
  • Rotate away from legacy cli/verify-* files that could be included in release packages; strip them in bin/release.sh.
  • Add directory hardening for cron/ / cli/, and use timing-safe comparisons for cron restore validation.
  • Bump version to 1.17.3.
  • CI: fix MySQL 8 privilege parsing on Focal, quiet PHP 8.5 Travis log floods, restore PHPUnit 7-compatible phpunit.xml, align PHP minimum to 7.4.

Compatibility

Mostly backwards-compatible for upgrades from 1.17.2 and earlier:

  • Existing cron_secret / backup id, crontab jobs, and admin UI restore continue to work unchanged.
  • restore-info JSON field shape is unchanged; content is migrated into the backup directory on activation/admin load/next backup.
  • Pre-1.14.10 cron/restore-info.json is still renamed, then moved with the same migration.

Caveats:

  • On upgrade, a new per-install CLI secret is generated (legacy webroot verify files are not reused). Migration copies any existing restore-info into the backup directory, then removes legacy plugin-tree files.
  • WordPress plugin updates already wiped runtime files under the plugin cron/ / cli/ trees; after 1.17.3, once rewritten into the backup directory, that metadata survives later plugin updates.
  • If WordPress never loads after upgrade (site already broken), migration may not run until WP or a backup can run.
  • Downgrade 1.17.3 → 1.17.2: old code will not read backup-directory storage; emergency restore metadata is missing until the older version writes a new cron/restore-info-* on the next backup.

Test plan

  • PHPUnit: 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)
  • After upgrade/activation, confirm no cli/verify-*.php or cron/restore-info-*.json remain under the plugin directory
  • Confirm backup directory contains .boldgrid-cli-secret and restore-info-*.json after a backup
  • Confirm HTTP GET of .../cron/restore-info-.json under the plugin URL returns 404 (file not present in web tree)
  • Confirm scheduled/cron backup and restore still work with a valid site id + cron secret
  • Confirm admin UI restore still works for a user with update_plugins
  • Confirm wrong cron secret still returns “Error: Invalid request.”
  • Upgrade smoke from 1.17.2: existing cron jobs and admin restore still work after first admin load/backup

Note

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 and restore-info-*.json are stored in the backup directory (.boldgrid-cli-secret), with restore locators in the plugin tree and wp-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 uses hash_equals, sensitive paths are excluded from archives and stripped from release zips, and Apache .htaccess blocks direct access where applicable.

A new Boldgrid_Backup_Admin_Zip layer 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 2 installed.json.

CI targets PHP 7.4 (release) and PHP 8.5 (custom install), with adjusted PHPUnit on newer PHP. Smaller fixes: MySQL 8 SHOW GRANTS privilege detection, FTPS disconnect on PHP 8 FTP\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.

cssjoe added 3 commits July 22, 2026 18:38
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.
@cssjoe
cssjoe requested a review from a team July 22, 2026 19:10
@cssjoe cssjoe self-assigned this Jul 22, 2026
@cssjoe cssjoe added this to the 1.17.3 milestone Jul 22, 2026
@cssjoe
cssjoe requested review from Copilot and jacobd91 July 22, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-*.json out 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.

Comment thread cli/class-info.php
Comment thread cli/class-info.php Outdated
Comment thread cli/env-info.php
Comment thread cli/class-info.php
Comment thread cli/class-info.php
cssjoe added 2 commits July 22, 2026 19:40
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.
@cssjoe

cssjoe commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

CI follow-up (dfd38d0)

Travis failures on Focal/MySQL 8 and PHP 8.5 addressed:

  • MySQL 8 grants: get_db_privileges() now parses enumerated *.* privilege lists (not only GRANT ALL PRIVILEGES), which unblocks VIEW dumps / archive tests and DB import view tests.
  • PHP 8.5 log length: skip --debug --verbose on non-7.4 jobs and suppress deprecations in that job (error_reporting=22527).
  • PHPUnit 7 config: restore filter/whitelist phpunit.xml so PHPUnit 7.5 validates on the 7.4 job.
  • PHP minimum: align declared requirement to 7.4 (composer.json, readme, support/CLI checks).

Local check: --filter 'Test_Boldgrid_Backup_Admin_Db_Import|Test_Boldgrid_Backup_Admin_Migrate_Util|Test_Boldgrid_Backup_Cli_Info|Test_Boldgrid_Backup_Admin_Cron' → OK (38 tests, 82 assertions).

Register the library version from Composer 2 installed.json before Load,
and skip the library activation hook that would overwrite it with null.
@cssjoe

cssjoe commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Follow-up: Composer 2 library load (5f11848)

After the Composer 2 CI/deps update, plugin activation could fatal with Class "Boldgrid\Library\Library\Ui\Card" not found, plus warnings from library Version.php / Load.php.

Cause: Composer 2 nests packages under packages in installed.json. Library version detection still expects Composer 1’s flat list, so it stored a null library version and never registered the PSR-4 path.

Fix (plugin-side, no vendor edits):

  • Register the real boldgrid/library version from Composer 2 installed.json before Load
  • Remove the library activation register() callback that would overwrite that version with null and emit warnings

Same pattern as Post and Page Builder. Verified activate is clean and Ui\Card loads.

jacobd91
jacobd91 previously approved these changes Jul 23, 2026
is_resource() is false for FTP\Connection, so ftp_close() never ran and FTPS teardown logged OpenSSL unexpected EOF on request shutdown.
cssjoe added 4 commits July 23, 2026 17:06
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 @since 1.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 );

Comment thread admin/class-boldgrid-backup-admin.php Outdated
Comment thread admin/class-boldgrid-backup-admin-folder-exclusion.php Outdated
Comment thread cli/env-info.php Outdated
Comment thread cli/class-info.php
Correct @SInCE tags, broaden restore-info backup exclusion, use a
proper 403 Forbidden reason phrase, and fail closed when secret
persistence fails.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread cli/class-info.php Outdated
Comment thread admin/class-boldgrid-backup-admin-db-import.php Outdated
Comment thread cli/class-info.php
jacobd91
jacobd91 previously approved these changes Jul 23, 2026
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.
Comment thread cli/class-info.php Outdated
Comment thread cli/class-info.php
- 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
Comment thread cli/class-info.php Outdated
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.
Comment thread admin/class-boldgrid-backup-admin-zip.php
Comment thread cli/class-info.php
Comment thread cli/class-info.php
Comment thread cli/class-info.php
Comment thread admin/class-boldgrid-backup-admin-folder-exclusion.php
…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
Comment thread cli/class-info.php
cssjoe added 2 commits July 24, 2026 19:08
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.
Comment thread cli/class-info.php
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.
Comment thread cli/class-info.php
…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.
Comment thread admin/class-boldgrid-backup-admin-zip.php
Comment thread admin/class-boldgrid-backup-admin-zip.php
Comment thread cli/class-info.php
Comment thread cli/class-info.php
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.
Comment thread admin/class-boldgrid-backup-admin-zip.php Outdated
Comment thread admin/class-boldgrid-backup-admin-folder-exclusion.php
cssjoe and others added 2 commits July 24, 2026 19:49
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.
Comment thread cli/class-info.php
Comment thread admin/class-boldgrid-backup-admin-zip.php
Comment thread cli/class-info.php
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.
Comment thread cli/class-info.php
Comment thread admin/class-boldgrid-backup-admin.php
Comment thread admin/compressor/class-boldgrid-backup-admin-compressor-pcl-zip.php Outdated
Comment thread admin/class-boldgrid-backup-admin-zip.php Outdated
- 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.
Comment thread cli/class-info.php
Comment thread cli/class-site-restore.php
Comment thread admin/class-boldgrid-backup-admin-folder-exclusion.php
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

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.
  • ✅ 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 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit f25d65c. Configure here.

Comment thread boldgrid-backup.php
Comment thread admin/class-boldgrid-backup-admin-zip.php
Comment thread admin/class-boldgrid-backup-admin-zip.php
- 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.
@cssjoe

cssjoe commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Status update (2026-07-24)

HEAD is 61b19ea on ENG7-4430-cron-sec.

Checks

  • Travis CI (Pull Request): success
  • Travis CI (Branch): success
  • Cursor Bugbot: success
  • Review threads: 0 unresolved

Since last human approval

Jacob’s earlier approvals were dismissed by follow-up commits. Notable later work (high level):

  • Fail-closed ZIP open/create when ZIP64 repair cannot complete; emergency CLI repair before extract
  • Secure-storage gaps: restore prior locators on setup failure; legacy cleanup alignment; greenfield/migrate rollback hardening
  • Restore-info write failures surfaced as unsuccessful/warnings instead of silent full success
  • WP 6.7+ init deferral tweak (doing_action('init') guard); Composer 2 library load; CI/PHP 8.5 / MySQL 8 / FTP fixes already on the branch

Ready for re-review

Please 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 $needs_classic_fix in the ZIP helper; align Admin_Config init deferral with the same doing_action pattern used elsewhere.

Internal tracking: ENG7-4430 (Dev ENG7-4431 / Review ENG7-4432).

@cssjoe
cssjoe requested a review from jacobd91 July 24, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants