PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / backup / archive-writer.php

archive-writer.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/backup/archive-writer.php

230 lines 6.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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