diff --git a/projects/plugins/boost/app/modules/optimizations/page-cache/class-page-cache-setup.php b/projects/plugins/boost/app/modules/optimizations/page-cache/class-page-cache-setup.php index a54cc902bc4a..27ab742bf382 100644 --- a/projects/plugins/boost/app/modules/optimizations/page-cache/class-page-cache-setup.php +++ b/projects/plugins/boost/app/modules/optimizations/page-cache/class-page-cache-setup.php @@ -8,7 +8,6 @@ use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings; use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils; use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger; -use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Simple_Delete; class Page_Cache_Setup { @@ -314,7 +313,7 @@ public static function uninstall() { self::deactivate(); // Call the Cache Preload module deactivation here to ensure it's cleaned up properly. Cache_Preload::deactivate(); - $result = Filesystem_Utils::iterate_directory( WP_CONTENT_DIR . '/boost-cache', new Simple_Delete() ); + $result = Filesystem_Utils::delete_directory( WP_CONTENT_DIR . '/boost-cache' ); if ( $result instanceof Boost_Cache_Error ) { return $result->to_wp_error(); } diff --git a/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/class-filesystem-utils.php b/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/class-filesystem-utils.php index ba9cd00d1b99..332d373a6422 100644 --- a/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/class-filesystem-utils.php +++ b/projects/plugins/boost/app/modules/optimizations/page-cache/pre-wordpress/class-filesystem-utils.php @@ -76,6 +76,88 @@ public static function iterate_files( $path, Path_Action $action ) { return $count; } + /** + * Recursively delete a directory and everything in it, including cache files, + * index.html placeholder files, subdirectories and the directory itself. + * + * Unlike iterate_directory() with a Simple_Delete action, this does not keep + * index.html placeholder files, does not log each deletion, and removes each + * entry as the iterator visits it instead of building a file list in memory, + * so it stays time- and memory-efficient even for very large caches. Used to + * completely remove the boost-cache directory when the plugin is uninstalled. + * + * @param string $path - The directory to delete. + * @return bool|Boost_Cache_Error - True on success (or if the directory is already gone), Boost_Cache_Error on failure. + */ + public static function delete_directory( $path ) { + clearstatcache(); + + // Refuse to follow a symlinked cache root. realpath() resolves a symlink + // to its target, so a boost-cache symlink pointing outside wp-content would + // resolve identically to $cache_root below and pass the containment check, + // causing the target tree to be deleted. Boost never creates boost-cache as + // a symlink, so a symlinked root is unexpected and we refuse it outright. + // This is checked on the literal $path, not the resolved target, and only + // guards the root itself; symlinks encountered inside the tree are unlinked + // (never followed) by the deletion loop below. + if ( is_link( $path ) ) { + return new Boost_Cache_Error( 'invalid-directory', 'Refusing to delete a symlinked directory: ' . $path ); + } + + $resolved = realpath( $path ); + if ( false === $resolved ) { + // Nothing to delete if the directory is already gone. + return true; + } + + // Strict containment check. is_boost_cache_directory() only does a substring + // match, which would also accept sibling paths like boost-cache-old; since + // this helper deletes whole trees during uninstall, only the cache root + // itself or paths inside it are accepted, compared on resolved paths. + $cache_root = realpath( WP_CONTENT_DIR . '/boost-cache' ); + if ( false === $cache_root || ( $resolved !== $cache_root && strpos( $resolved, $cache_root . '/' ) !== 0 ) ) { + return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory ' . $path ); + } + + if ( ! is_dir( $resolved ) ) { + return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' ); + } + + // Deleting a large cache can take a while; try not to time out half-way through. + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $resolved, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + // Errors for individual entries are suppressed so a single failure doesn't abort the cleanup. + foreach ( $iterator as $file ) { + if ( $file->isDir() && ! $file->isLink() ) { + @rmdir( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + } else { + @unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged + } + } + } catch ( \Throwable $e ) { + // The iterator itself can throw (e.g. an unreadable subdirectory). + // Uninstall cleanup must fail with a controlled error, not an + // uncaught exception. + return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $e->getMessage() ); + } + + @rmdir( $resolved ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + + if ( is_dir( $resolved ) ) { + return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $path ); + } + + return true; + } + private static function validate_path( $path ) { $path = realpath( $path ); if ( ! $path ) { diff --git a/projects/plugins/boost/changelog/fix-page-cache-uninstall-cleanup b/projects/plugins/boost/changelog/fix-page-cache-uninstall-cleanup new file mode 100644 index 000000000000..e1637e73c0ee --- /dev/null +++ b/projects/plugins/boost/changelog/fix-page-cache-uninstall-cleanup @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Page Cache: remove the boost-cache directory completely on uninstall, and prevent cleanup from hanging or timing out on very large caches. diff --git a/projects/plugins/boost/tests/php/modules/optimizations/page-cache/Filesystem_Utils_Test.php b/projects/plugins/boost/tests/php/modules/optimizations/page-cache/Filesystem_Utils_Test.php index 56cd4fc41e5d..2bad48e61e98 100644 --- a/projects/plugins/boost/tests/php/modules/optimizations/page-cache/Filesystem_Utils_Test.php +++ b/projects/plugins/boost/tests/php/modules/optimizations/page-cache/Filesystem_Utils_Test.php @@ -16,6 +16,10 @@ class Filesystem_Utils_Test extends TestCase { public function setUp(): void { parent::setUp(); + if ( ! defined( 'WP_CONTENT_DIR' ) ) { + define( 'WP_CONTENT_DIR', '/tmp/wordpress/wp-content' ); + } + // Create a temporary test directory $this->test_dir = sys_get_temp_dir() . '/boost-test-' . uniqid(); $this->boost_cache_dir = WP_CONTENT_DIR . '/boost-cache'; @@ -161,6 +165,201 @@ public function test_gc_expired_files() { $this->assertTrue( file_exists( $file2 ) ); } + public function test_delete_directory_removes_entire_tree() { + // Mimic the layout of a real boost-cache directory, including index.html + // placeholder files in every directory, and the logs/ and static/ subdirectories. + mkdir( $this->boost_cache_dir . '/cache/example.com/some/page', 0755, true ); + mkdir( $this->boost_cache_dir . '/logs', 0755, true ); + mkdir( $this->boost_cache_dir . '/static', 0755, true ); + + file_put_contents( $this->boost_cache_dir . '/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/cache/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/cache/example.com/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/cache/example.com/some/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/cache/example.com/some/page/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/cache/example.com/some/page/' . md5( 'request' ) . '.html', 'cached page' ); + file_put_contents( $this->boost_cache_dir . '/logs/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/logs/log-2026-06-11.log.php', 'log data' ); + file_put_contents( $this->boost_cache_dir . '/static/index.html', '' ); + file_put_contents( $this->boost_cache_dir . '/static/file.css', 'css' ); + + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + $this->assertTrue( $result ); + $this->assertFalse( file_exists( $this->boost_cache_dir ) ); + } + + public function test_delete_directory_with_deep_and_many_file_tree() { + // Many files spread across many subdirectories. + for ( $i = 0; $i < 20; $i++ ) { + $dir = $this->boost_cache_dir . '/cache/example.com/page-' . $i; + mkdir( $dir, 0755, true ); + file_put_contents( $dir . '/index.html', '' ); + for ( $j = 0; $j < 50; $j++ ) { + file_put_contents( $dir . '/file-' . $j . '.html', 'cached content' ); + } + } + + // A deeply nested directory tree. + $deep_dir = $this->boost_cache_dir . '/cache/deep'; + for ( $i = 0; $i < 30; $i++ ) { + $deep_dir .= '/level-' . $i; + } + mkdir( $deep_dir, 0755, true ); + file_put_contents( $deep_dir . '/leaf.html', 'cached content' ); + + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + $this->assertTrue( $result ); + $this->assertFalse( is_dir( $this->boost_cache_dir ) ); + } + + public function test_delete_directory_refuses_paths_outside_boost_cache() { + file_put_contents( $this->test_dir . '/test.html', 'Test content' ); + + $result = Filesystem_Utils::delete_directory( $this->test_dir ); + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'invalid-directory', $result->get_error_code() ); + $this->assertTrue( is_dir( $this->test_dir ) ); + $this->assertTrue( file_exists( $this->test_dir . '/test.html' ) ); + } + + public function test_delete_directory_refuses_sibling_directory_with_boost_cache_prefix() { + // A sibling like boost-cache-old passes is_boost_cache_directory()'s + // substring match, so delete_directory()'s own strict containment + // check is what must refuse it. + $sibling = $this->boost_cache_dir . '-old'; + mkdir( $sibling, 0755, true ); + file_put_contents( $sibling . '/test.html', 'Test content' ); + + try { + $result = Filesystem_Utils::delete_directory( $sibling ); + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'invalid-directory', $result->get_error_code() ); + $this->assertTrue( is_dir( $sibling ) ); + $this->assertTrue( file_exists( $sibling . '/test.html' ) ); + } finally { + $this->recursive_rmdir( $sibling ); + } + } + + public function test_delete_directory_refuses_file_path() { + $file = $this->boost_cache_dir . '/cached-page.html'; + file_put_contents( $file, 'cached page' ); + + $result = Filesystem_Utils::delete_directory( $file ); + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'not-a-directory', $result->get_error_code() ); + $this->assertTrue( file_exists( $file ) ); + } + + public function test_delete_directory_returns_error_for_unreadable_subdirectory() { + if ( function_exists( 'posix_geteuid' ) && 0 === posix_geteuid() ) { + $this->markTestSkipped( 'Directory permission restrictions do not apply when running as root.' ); + } + + $locked = $this->boost_cache_dir . '/cache/locked'; + mkdir( $locked, 0755, true ); + file_put_contents( $locked . '/file.html', 'cached page' ); + chmod( $locked, 0000 ); + + try { + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'could-not-delete-directory', $result->get_error_code() ); + } finally { + chmod( $locked, 0755 ); + } + } + + public function test_delete_directory_with_missing_directory() { + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir . '/non-existent' ); + $this->assertTrue( $result ); + } + + public function test_delete_directory_unlinks_symlink_without_deleting_target() { + if ( ! function_exists( 'symlink' ) ) { + $this->markTestSkipped( 'symlink() is not available on this platform.' ); + } + + // A directory outside boost-cache, with a file in it, that must survive. + $external_target = $this->test_dir . '/external-target'; + mkdir( $external_target, 0755, true ); + file_put_contents( $external_target . '/keep.txt', 'must survive' ); + + // A symlink INSIDE the cache tree that points at the external directory. + // The deletion loop must unlink the link itself, not follow it. + if ( ! @symlink( $external_target, $this->boost_cache_dir . '/link-to-outside' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $this->markTestSkipped( 'Could not create a symlink on this platform.' ); + } + file_put_contents( $this->boost_cache_dir . '/cached.html', 'cached page' ); + + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + + $this->assertTrue( $result ); + $this->assertFalse( is_dir( $this->boost_cache_dir ) ); + // The link was removed but its target and contents are untouched. + $this->assertTrue( is_dir( $external_target ) ); + $this->assertTrue( file_exists( $external_target . '/keep.txt' ) ); + } + + public function test_delete_directory_refuses_symlinked_cache_root() { + if ( ! function_exists( 'symlink' ) ) { + $this->markTestSkipped( 'symlink() is not available on this platform.' ); + } + + // A directory outside boost-cache that must survive. + $external_target = $this->test_dir . '/external-root-target'; + mkdir( $external_target, 0755, true ); + file_put_contents( $external_target . '/keep.txt', 'must survive' ); + + // Replace the boost-cache root itself with a symlink to the external + // directory. realpath() resolves both sides identically, so the + // containment check passes; the is_link() guard is what must refuse it. + rmdir( $this->boost_cache_dir ); + if ( ! @symlink( $external_target, $this->boost_cache_dir ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + mkdir( $this->boost_cache_dir, 0755, true ); + $this->markTestSkipped( 'Could not create a symlink on this platform.' ); + } + + try { + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'invalid-directory', $result->get_error_code() ); + $this->assertTrue( is_dir( $external_target ) ); + $this->assertTrue( file_exists( $external_target . '/keep.txt' ) ); + } finally { + // Remove the symlink so tearDown does not follow it into the target. + if ( is_link( $this->boost_cache_dir ) ) { + unlink( $this->boost_cache_dir ); + } + } + } + + public function test_delete_directory_returns_error_when_root_rmdir_fails() { + if ( function_exists( 'posix_geteuid' ) && 0 === posix_geteuid() ) { + $this->markTestSkipped( 'Directory permission restrictions do not apply when running as root.' ); + } + + // Files inside the cache delete fine, but a read-only parent makes the + // final rmdir() of the root fail. This exercises the post-iteration + // is_dir() guard, a different code path than the iterator-throw case. + file_put_contents( $this->boost_cache_dir . '/cached.html', 'cached page' ); + $parent = dirname( $this->boost_cache_dir ); + chmod( $parent, 0555 ); + + try { + $result = Filesystem_Utils::delete_directory( $this->boost_cache_dir ); + + $this->assertInstanceOf( Boost_Cache_Error::class, $result ); + $this->assertEquals( 'could-not-delete-directory', $result->get_error_code() ); + // The contents were removed but the root itself could not be. + $this->assertTrue( is_dir( $this->boost_cache_dir ) ); + $this->assertFalse( file_exists( $this->boost_cache_dir . '/cached.html' ) ); + } finally { + chmod( $parent, 0755 ); + } + } + public function test_invalid_directory_operations() { $non_existent_dir = $this->boost_cache_dir . '/non-existent'; $invalid_dir = $this->test_dir;