| 1 |
<?php |
| 2 |
/** |
| 3 |
* The ONE path through which anything is ever deleted. |
| 4 |
* |
| 5 |
* @package Templately |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Templately\Modules\Utilities\Cleanup; |
| 9 |
|
| 10 |
use RecursiveIteratorIterator; |
| 11 |
use SplFileInfo; |
| 12 |
use WP_Error; |
| 13 |
|
| 14 |
/** |
| 15 |
* No cleanup task holds a deletion capability of its own. Every removal — from |
| 16 |
* every task, including ones written later by anyone — comes through here, so |
| 17 |
* the safety rules have exactly one implementation and cannot be forgotten by a |
| 18 |
* contributor who did not read the interface docs. |
| 19 |
* |
| 20 |
* `Scanner` deliberately no longer exposes `delete_tree()`. It measures and it |
| 21 |
* validates paths; it does not delete. That is what makes "exactly one |
| 22 |
* implementation of the safety rules" a structural fact rather than a |
| 23 |
* convention — a task cannot reach a raw recursive delete even by accident. |
| 24 |
* |
| 25 |
* Four rules, enforced here for everyone: |
| 26 |
* |
| 27 |
* 1. CONTAINMENT — the resolved real path must sit inside the plugin's own |
| 28 |
* uploads root. The check uses a trailing separator, so a sibling directory |
| 29 |
* sharing a name prefix (`/uploads/templately-evil`) is refused rather than |
| 30 |
* passing a naive `strpos()` test. |
| 31 |
* 2. SYMLINKS — never followed. A link is unlinked as a link; its target is |
| 32 |
* never traversed and never removed. |
| 33 |
* 3. GUARD FILES — `.htaccess`, `index.php`, `index.html` and `web.config` are |
| 34 |
* immortal, anywhere under the root. These are what keep a web-reachable |
| 35 |
* uploads directory private, they are written once and therefore always the |
| 36 |
* oldest thing present, and deleting them was a real shipped defect (see |
| 37 |
* spec 025 FR-015). `web.config` is the IIS equivalent of the `.htaccess` |
| 38 |
* deny rule, written by `Helper::protect_directory()`; it has to be on this |
| 39 |
* list for the same reason the other three are, and it is easy to miss |
| 40 |
* because Apache sites never see one. |
| 41 |
* 4. LIVE FILES — a caller may declare paths that are being written to right |
| 42 |
* now; they are never candidates. |
| 43 |
*/ |
| 44 |
final class Remover { |
| 45 |
|
| 46 |
/** |
| 47 |
* Filenames no task may ever remove. |
| 48 |
* |
| 49 |
* Not configurable. A contributor who thinks they need to delete one of |
| 50 |
* these is wrong: they exist to keep the directory unreadable from the web. |
| 51 |
*/ |
| 52 |
const GUARD_FILES = [ '.htaccess', 'index.php', 'index.html', 'web.config' ]; |
| 53 |
|
| 54 |
/** @var string[] Absolute paths excluded for this request (live logs, etc.). */ |
| 55 |
private static $protected = []; |
| 56 |
|
| 57 |
/** |
| 58 |
* Protect paths that are in use right now — a log being appended to, the |
| 59 |
* working directory of a running import. |
| 60 |
* |
| 61 |
* @param string[] $paths Absolute paths. |
| 62 |
*/ |
| 63 |
public static function protect( array $paths ): void { |
| 64 |
foreach ( $paths as $path ) { |
| 65 |
$real = realpath( $path ); |
| 66 |
if ( false !== $real ) { |
| 67 |
self::$protected[ $real ] = true; |
| 68 |
} |
| 69 |
} |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Test seam. |
| 74 |
*/ |
| 75 |
public static function reset_protected(): void { |
| 76 |
self::$protected = []; |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* Remove a directory and everything inside it. |
| 81 |
* |
| 82 |
* @param string $target Absolute path, or one relative to the uploads root. |
| 83 |
* @param Context $context Honours `dry_run` — nothing is touched when set. |
| 84 |
* @param bool $keep_root Empty the directory but keep the directory itself. |
| 85 |
* @return TaskResult|WP_Error Bytes/items, or a refusal explaining why. |
| 86 |
*/ |
| 87 |
public static function delete_directory( string $target, Context $context, bool $keep_root = false ) { |
| 88 |
$resolved = Scanner::resolve_target( $target ); |
| 89 |
if ( is_wp_error( $resolved ) ) { |
| 90 |
return $resolved; |
| 91 |
} |
| 92 |
|
| 93 |
// Refusing the root outright: emptying the whole uploads tree is never |
| 94 |
// what an individual task means, and a task that computed its way to '' |
| 95 |
// has a bug we should surface rather than act on. |
| 96 |
if ( $resolved['is_root'] && ! $keep_root ) { |
| 97 |
return new WP_Error( |
| 98 |
'refuse_root_delete', |
| 99 |
__( 'Refusing to remove the Templately uploads root.', 'templately' ), |
| 100 |
[ 'status' => 400 ] |
| 101 |
); |
| 102 |
} |
| 103 |
|
| 104 |
if ( isset( self::$protected[ $resolved['path'] ] ) ) { |
| 105 |
return new WP_Error( |
| 106 |
'target_in_use', |
| 107 |
__( 'That directory is in use.', 'templately' ), |
| 108 |
[ 'status' => 409 ] |
| 109 |
); |
| 110 |
} |
| 111 |
|
| 112 |
if ( $context->is_dry_run() ) { |
| 113 |
$measured = Scanner::measure_dir( $resolved['path'] ); |
| 114 |
|
| 115 |
return TaskResult::empty()->add( (int) $measured['files'], (int) $measured['bytes'] ); |
| 116 |
} |
| 117 |
|
| 118 |
return self::delete_tree( $resolved['path'], $keep_root ); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Remove a single file. |
| 123 |
* |
| 124 |
* @param string $path Absolute path, or one relative to the uploads root. |
| 125 |
* @param Context $context Honours `dry_run`. |
| 126 |
* @return TaskResult|WP_Error |
| 127 |
*/ |
| 128 |
public static function delete_file( string $path, Context $context ) { |
| 129 |
$base = realpath( Scanner::get_base_dir() ); |
| 130 |
if ( false === $base ) { |
| 131 |
return new WP_Error( |
| 132 |
'uploads_base_missing', |
| 133 |
__( 'The Templately uploads directory does not exist.', 'templately' ), |
| 134 |
[ 'status' => 404 ] |
| 135 |
); |
| 136 |
} |
| 137 |
$base = rtrim( $base, '/\\' ); |
| 138 |
|
| 139 |
$real = realpath( $path ); |
| 140 |
if ( false === $real ) { |
| 141 |
// Already gone. Not an error — a task that raced another sweep, or a |
| 142 |
// record whose file was removed by hand, should not report a failure. |
| 143 |
return TaskResult::empty(); |
| 144 |
} |
| 145 |
|
| 146 |
// Same strict containment as directories: the trailing separator is what |
| 147 |
// stops `/uploads/templately-evil/x.log` passing as ours. |
| 148 |
if ( 0 !== strpos( $real, $base . DIRECTORY_SEPARATOR ) ) { |
| 149 |
return new WP_Error( |
| 150 |
'target_outside_base', |
| 151 |
__( 'That path is outside the Templately uploads directory.', 'templately' ), |
| 152 |
[ 'status' => 400 ] |
| 153 |
); |
| 154 |
} |
| 155 |
|
| 156 |
if ( self::is_guard_file( $real ) ) { |
| 157 |
return new WP_Error( |
| 158 |
'refuse_guard_file', |
| 159 |
__( 'That file protects the uploads directory and cannot be removed.', 'templately' ), |
| 160 |
[ 'status' => 400 ] |
| 161 |
); |
| 162 |
} |
| 163 |
|
| 164 |
if ( isset( self::$protected[ $real ] ) ) { |
| 165 |
return new WP_Error( |
| 166 |
'target_in_use', |
| 167 |
__( 'That file is in use.', 'templately' ), |
| 168 |
[ 'status' => 409 ] |
| 169 |
); |
| 170 |
} |
| 171 |
|
| 172 |
if ( is_dir( $real ) && ! is_link( $real ) ) { |
| 173 |
return new WP_Error( |
| 174 |
'target_is_a_directory', |
| 175 |
__( 'That path is a directory.', 'templately' ), |
| 176 |
[ 'status' => 400 ] |
| 177 |
); |
| 178 |
} |
| 179 |
|
| 180 |
$size = (int) @filesize( $real ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 181 |
|
| 182 |
if ( $context->is_dry_run() ) { |
| 183 |
return TaskResult::empty()->add( 1, $size ); |
| 184 |
} |
| 185 |
|
| 186 |
if ( ! @unlink( $real ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 187 |
return TaskResult::empty()->fail( sprintf( 'Could not remove %s', basename( $real ) ) ); |
| 188 |
} |
| 189 |
|
| 190 |
return TaskResult::empty()->add( 1, $size ); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Whether a path is one of the directory's protective files. |
| 195 |
*/ |
| 196 |
public static function is_guard_file( string $path ): bool { |
| 197 |
return in_array( basename( $path ), self::GUARD_FILES, true ); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* The recursive removal itself. PRIVATE — this is the capability the whole |
| 202 |
* class exists to keep out of task authors' hands. |
| 203 |
* |
| 204 |
* Relocated verbatim from the developer-only uploads scanner, which is why |
| 205 |
* its symlink and failure-tolerance behaviour is unchanged: children are |
| 206 |
* visited CHILD_FIRST so directories are empty by the time they are removed, |
| 207 |
* a symlink is unlinked as a link and never followed, and a file that cannot |
| 208 |
* be removed is simply not counted rather than aborting a partial delete. |
| 209 |
* |
| 210 |
* Guard files are skipped here too, not only in `delete_file()` — a |
| 211 |
* directory delete must never take the root's guards with it. |
| 212 |
*/ |
| 213 |
private static function delete_tree( string $path, bool $keep_root = false ): TaskResult { |
| 214 |
$result = TaskResult::empty(); |
| 215 |
|
| 216 |
if ( ! is_dir( $path ) ) { |
| 217 |
return $result; |
| 218 |
} |
| 219 |
|
| 220 |
$iterator = Scanner::make_recursive_iterator( $path, RecursiveIteratorIterator::CHILD_FIRST ); |
| 221 |
if ( null === $iterator ) { |
| 222 |
return $result->fail( sprintf( 'Could not read %s', basename( $path ) ) ); |
| 223 |
} |
| 224 |
|
| 225 |
foreach ( $iterator as $item ) { |
| 226 |
/** @var SplFileInfo $item */ |
| 227 |
$item_path = $item->getPathname(); |
| 228 |
|
| 229 |
// isLink() FIRST: a symlink to a directory answers isDir() === true, |
| 230 |
// and we must remove the LINK, never walk into its target. |
| 231 |
if ( $item->isLink() ) { |
| 232 |
@unlink( $item_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 233 |
continue; |
| 234 |
} |
| 235 |
|
| 236 |
if ( $item->isDir() ) { |
| 237 |
if ( @rmdir( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 238 |
$result->add_dirs( 1 ); |
| 239 |
} |
| 240 |
continue; |
| 241 |
} |
| 242 |
|
| 243 |
if ( $keep_root && self::is_guard_file( $item_path ) ) { |
| 244 |
// Emptying a directory we are keeping must not strip the guards |
| 245 |
// that keep it private. |
| 246 |
continue; |
| 247 |
} |
| 248 |
|
| 249 |
$size = Scanner::safe_size( $item ); |
| 250 |
if ( @unlink( $item_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 251 |
$result->add( 1, $size ); |
| 252 |
} |
| 253 |
} |
| 254 |
|
| 255 |
if ( ! $keep_root && @rmdir( $path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 256 |
$result->add_dirs( 1 ); |
| 257 |
} |
| 258 |
|
| 259 |
Scanner::bust_cache(); |
| 260 |
|
| 261 |
return $result; |
| 262 |
} |
| 263 |
} |
| 264 |
|