Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ) );
}
Comment thread
kraftbj marked this conversation as resolved.

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;
Expand Down
Loading