| 1 |
<?php |
| 2 |
/** |
| 3 |
* Memory-safe ZIP helpers for the backup module. |
| 4 |
* |
| 5 |
* @version 1.0.0 |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace StoreEngine\Backup; |
| 9 |
|
| 10 |
use StoreEngine\Classes\Exceptions\StoreEngineException; |
| 11 |
use ZipArchive; |
| 12 |
|
| 13 |
if ( ! defined( 'ABSPATH' ) ) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
class ArchiveWriter { |
| 18 |
|
| 19 |
/** |
| 20 |
* Recursively zip a directory (entries added via addFile so large jsonl |
| 21 |
* files are streamed by the zip extension, never loaded into memory). |
| 22 |
* |
| 23 |
* @throws StoreEngineException |
| 24 |
*/ |
| 25 |
public static function zip_dir( string $src_dir, string $dest_zip ): void { |
| 26 |
if ( ! class_exists( ZipArchive::class ) ) { |
| 27 |
throw new StoreEngineException( 'PHP ZipArchive extension is required for backups.', 'backup-no-zip' ); |
| 28 |
} |
| 29 |
|
| 30 |
$src_dir = rtrim( $src_dir, '/\\' ); |
| 31 |
$zip = new ZipArchive(); |
| 32 |
if ( true !== $zip->open( $dest_zip, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { |
| 33 |
throw new StoreEngineException( 'Unable to create backup archive.', 'backup-zip-open' ); |
| 34 |
} |
| 35 |
|
| 36 |
/** @var \SplFileInfo[] $iterator */ |
| 37 |
$iterator = new \RecursiveIteratorIterator( |
| 38 |
new \RecursiveDirectoryIterator( $src_dir, \FilesystemIterator::SKIP_DOTS ), |
| 39 |
\RecursiveIteratorIterator::SELF_FIRST |
| 40 |
); |
| 41 |
|
| 42 |
foreach ( $iterator as $item ) { |
| 43 |
$path = $item->getPathname(); |
| 44 |
$relative = ltrim( substr( $path, strlen( $src_dir ) ), '/\\' ); |
| 45 |
$relative = str_replace( '\\', '/', $relative ); |
| 46 |
|
| 47 |
if ( $item->isDir() ) { |
| 48 |
$zip->addEmptyDir( $relative ); |
| 49 |
} else { |
| 50 |
$zip->addFile( $path, $relative ); |
| 51 |
} |
| 52 |
} |
| 53 |
|
| 54 |
$zip->close(); |
| 55 |
} |
| 56 |
|
| 57 |
/** |
| 58 |
* Append a directory tree into an existing zip under an internal prefix, |
| 59 |
* streaming each file (no temp copy). Used for the optional "files" group so |
| 60 |
* we never duplicate large uploads to disk. |
| 61 |
* |
| 62 |
* @param string|string[]|null $skip_real Absolute realpath(s) to subdir(s) to |
| 63 |
* exclude (e.g. the backups dir, or the |
| 64 |
* deployment versioned-files dir). |
| 65 |
* |
| 66 |
* @throws StoreEngineException |
| 67 |
*/ |
| 68 |
public static function append_dir( string $zip_path, string $src_dir, string $internal_prefix, $skip_real = null ): void { |
| 69 |
if ( ! is_dir( $src_dir ) ) { |
| 70 |
return; |
| 71 |
} |
| 72 |
$zip = new ZipArchive(); |
| 73 |
if ( true !== $zip->open( $zip_path ) ) { |
| 74 |
throw new StoreEngineException( 'Unable to open archive to append files.', 'backup-zip-append' ); |
| 75 |
} |
| 76 |
|
| 77 |
$skips = array_filter( array_map( |
| 78 |
'strval', |
| 79 |
is_array( $skip_real ) ? $skip_real : [ $skip_real ] |
| 80 |
) ); |
| 81 |
|
| 82 |
$src_dir = rtrim( $src_dir, '/\\' ); |
| 83 |
$iterator = new \RecursiveIteratorIterator( |
| 84 |
new \RecursiveDirectoryIterator( $src_dir, \FilesystemIterator::SKIP_DOTS ), |
| 85 |
\RecursiveIteratorIterator::SELF_FIRST |
| 86 |
); |
| 87 |
|
| 88 |
foreach ( $iterator as $item ) { |
| 89 |
$path = $item->getPathname(); |
| 90 |
$real = (string) realpath( $path ); |
| 91 |
foreach ( $skips as $skip ) { |
| 92 |
if ( str_starts_with( $real, $skip ) ) { |
| 93 |
continue 2; // never recurse into an excluded dir. |
| 94 |
} |
| 95 |
} |
| 96 |
if ( $item->isDir() ) { |
| 97 |
continue; |
| 98 |
} |
| 99 |
$relative = ltrim( substr( $path, strlen( $src_dir ) ), '/\\' ); |
| 100 |
$relative = $internal_prefix . '/' . str_replace( '\\', '/', $relative ); |
| 101 |
$zip->addFile( $path, $relative ); |
| 102 |
} |
| 103 |
|
| 104 |
$zip->close(); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Read a single entry from a zip without extracting everything (used to peek |
| 109 |
* at manifest.json during import inspection). |
| 110 |
*/ |
| 111 |
public static function read_entry( string $zip_path, string $entry ): ?string { |
| 112 |
if ( ! class_exists( ZipArchive::class ) ) { |
| 113 |
return null; |
| 114 |
} |
| 115 |
$zip = new ZipArchive(); |
| 116 |
if ( true !== $zip->open( $zip_path ) ) { |
| 117 |
return null; |
| 118 |
} |
| 119 |
$contents = $zip->getFromName( $entry ); |
| 120 |
$zip->close(); |
| 121 |
|
| 122 |
return false === $contents ? null : $contents; |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Extract an entire archive to a directory. |
| 127 |
* |
| 128 |
* @throws StoreEngineException |
| 129 |
*/ |
| 130 |
public static function unzip( string $zip_path, string $dest_dir ): void { |
| 131 |
if ( ! class_exists( ZipArchive::class ) ) { |
| 132 |
throw new StoreEngineException( 'PHP ZipArchive extension is required for restore.', 'backup-no-zip' ); |
| 133 |
} |
| 134 |
if ( ! is_dir( $dest_dir ) ) { |
| 135 |
wp_mkdir_p( $dest_dir ); |
| 136 |
} |
| 137 |
$zip = new ZipArchive(); |
| 138 |
if ( true !== $zip->open( $zip_path ) ) { |
| 139 |
throw new StoreEngineException( 'Unable to open backup archive.', 'backup-unzip-open' ); |
| 140 |
} |
| 141 |
|
| 142 |
// Zip Slip hardening: never hand the raw archive to extractTo() blindly — |
| 143 |
// a crafted entry name (../../wp-config.php, /etc/..., phar://...) could |
| 144 |
// write outside the restore working directory. Validate every entry name |
| 145 |
// first, then extract ONLY the safe set. Because each name passed to |
| 146 |
// extractTo() is a containment-checked relative path, the entries can only |
| 147 |
// land inside $dest_dir. |
| 148 |
$safe = []; |
| 149 |
for ( $i = 0; $i < $zip->numFiles; $i ++ ) { |
| 150 |
$name = $zip->getNameIndex( $i ); |
| 151 |
if ( false === $name || '' === $name ) { |
| 152 |
continue; |
| 153 |
} |
| 154 |
if ( ! self::is_safe_zip_entry( $name ) ) { |
| 155 |
$zip->close(); |
| 156 |
throw new StoreEngineException( |
| 157 |
esc_html( sprintf( 'Backup archive contains an unsafe path (%s) and was rejected.', $name ) ), |
| 158 |
'backup-unzip-unsafe-path' |
| 159 |
); |
| 160 |
} |
| 161 |
$safe[] = $name; |
| 162 |
} |
| 163 |
|
| 164 |
if ( $safe && true !== $zip->extractTo( $dest_dir, $safe ) ) { |
| 165 |
$zip->close(); |
| 166 |
throw new StoreEngineException( 'Failed to extract backup archive.', 'backup-unzip-extract' ); |
| 167 |
} |
| 168 |
|
| 169 |
$zip->close(); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Whether a zip entry name is safe to extract under a destination directory. |
| 174 |
* |
| 175 |
* Rejects absolute paths (unix `/…`, Windows `C:\…`), parent-directory |
| 176 |
* traversal (`..`), stream wrappers (`phar://`, `php://`, …) and null bytes — |
| 177 |
* the building blocks of a Zip Slip escape. |
| 178 |
*/ |
| 179 |
protected static function is_safe_zip_entry( string $name ): bool { |
| 180 |
$name = str_replace( '\\', '/', $name ); |
| 181 |
|
| 182 |
if ( '' === $name || '/' === $name[0] ) { |
| 183 |
return false; // Absolute (unix) path. |
| 184 |
} |
| 185 |
if ( preg_match( '#^[a-zA-Z]:#', $name ) ) { |
| 186 |
return false; // Absolute (Windows drive) path. |
| 187 |
} |
| 188 |
if ( false !== strpos( $name, '://' ) ) { |
| 189 |
return false; // Stream wrapper. |
| 190 |
} |
| 191 |
if ( false !== strpos( $name, "\0" ) ) { |
| 192 |
return false; // Null byte. |
| 193 |
} |
| 194 |
|
| 195 |
foreach ( explode( '/', $name ) as $segment ) { |
| 196 |
if ( '..' === $segment ) { |
| 197 |
return false; // Parent-directory traversal. |
| 198 |
} |
| 199 |
} |
| 200 |
|
| 201 |
return true; |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* Recursively delete a directory (cleanup of temp working dirs). |
| 206 |
*/ |
| 207 |
public static function rrmdir( string $dir ): void { |
| 208 |
if ( ! is_dir( $dir ) ) { |
| 209 |
return; |
| 210 |
} |
| 211 |
$items = new \RecursiveIteratorIterator( |
| 212 |
new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), |
| 213 |
\RecursiveIteratorIterator::CHILD_FIRST |
| 214 |
); |
| 215 |
foreach ( $items as $item ) { |
| 216 |
// phpcs:disable WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir |
| 217 |
if ( $item->isDir() ) { |
| 218 |
@rmdir( $item->getPathname() ); |
| 219 |
} else { |
| 220 |
@unlink( $item->getPathname() ); |
| 221 |
} |
| 222 |
// phpcs:enable |
| 223 |
} |
| 224 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir |
| 225 |
@rmdir( $dir ); |
| 226 |
} |
| 227 |
} |
| 228 |
|
| 229 |
// End of file archive-writer.php. |
| 230 |
|