*/ private static function roots() { return array( 'uploads' => 'uploads', 'themes' => 'themes', 'plugins' => 'plugins', 'muplugins' => 'mu-plugins', ); } /** * Write the file list; returns [count, total_bytes]. * * @param array $scope Scope flags. * @param array $excludes Extra path fragments to skip. * @param int $max_bytes Skip files bigger than this (0 = no cap). * @param string $list_path Where to write the newline-delimited list. * @return array{count:int,bytes:int} */ public static function build_list( array $scope, array $excludes, $max_bytes, $list_path ) { $res = self::build_indexed( $scope, $excludes, $max_bytes, $list_path ); return array( 'count' => $res['count'], 'bytes' => $res['bytes'] ); } /** * G1 — scan with optional indexing / differential filtering. * * - `$index_out` (base backups): also writes "rel\tsize\tmtime" for every file, so * later differentials can diff against this cycle's snapshot. * - `$base_index` (differentials): a map rel => "size:mtime"; only new/changed files * (size or mtime differs) are written to the list, and paths present in the base * but gone now are returned as `deletions`. * * @param array $scope Scope flags. * @param array $excludes Extra path fragments to skip. * @param int $max_bytes Skip files bigger than this (0 = no cap). * @param string $list_path Where to write the newline-delimited list. * @param string $index_out Optional index file to write (full snapshot). * @param array $base_index Optional base map rel => "size:mtime" (differential mode). * @return array{count:int,bytes:int,deletions:array,total:int} */ public static function build_indexed( array $scope, array $excludes, $max_bytes, $list_path, $index_out = '', array $base_index = array() ) { $content = rtrim( WP_CONTENT_DIR, '/\\' ); $backup_dir = Local_Store::dir(); $backup_dir = is_wp_error( $backup_dir ) ? '' : $backup_dir; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $fh = @fopen( $list_path, 'wb' ); if ( ! $fh ) { return array( 'count' => 0, 'bytes' => 0, 'deletions' => array(), 'total' => 0 ); } $ifh = ''; if ( '' !== $index_out ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $ifh = @fopen( $index_out, 'wb' ); } $differential = ! empty( $base_index ); $seen = array(); $count = 0; $bytes = 0; $total = 0; // Directories to walk: the four named wp-content sub-trees that are selected… $targets = array(); foreach ( self::roots() as $scope_key => $sub ) { if ( ! empty( $scope[ $scope_key ] ) && is_dir( $content . '/' . $sub ) ) { $targets[] = array( $content . '/' . $sub, $sub ); } } // …plus EVERYTHING ELSE in wp-content when the "other" bucket is on: loose // root files (drop-ins like object-cache.php) + any non-standard folder // (languages, custom plugin data, etc.). This is what a full-site backup must // include; without it the archive silently omits real site data. $top_files = array(); if ( ! empty( $scope['other'] ) ) { $named = array( 'uploads' => 1, 'themes' => 1, 'plugins' => 1, 'mu-plugins' => 1 ); $entries = @scandir( $content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged foreach ( (array) $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } $abs = $content . '/' . $entry; if ( is_dir( $abs ) ) { if ( isset( $named[ $entry ] ) ) { continue; // Already covered by the named roots above. } $targets[] = array( $abs, $entry ); } elseif ( is_file( $abs ) ) { $top_files[] = array( $abs, $entry ); } } } foreach ( $targets as $target ) { $abs = $target[0]; $sub = $target[1]; try { // Do NOT follow symlinks: a symlinked directory under wp-content would pull an // unintended external tree into the backup (or loop forever on a symlink cycle). // The callback filter returns false for any symlink, so the iterator neither // includes it nor descends into it. $filter = new \RecursiveCallbackFilterIterator( new \RecursiveDirectoryIterator( $abs, \FilesystemIterator::SKIP_DOTS ), static function ( $current ) { return ! $current->isLink(); } ); $iterator = new \RecursiveIteratorIterator( $filter, \RecursiveIteratorIterator::LEAVES_ONLY ); } catch ( \Exception $e ) { continue; } foreach ( $iterator as $file ) { if ( ! $file->isFile() ) { continue; } $abspath = $file->getPathname(); $rel = $sub . '/' . substr( $abspath, strlen( $abs ) + 1 ); $rel = str_replace( '\\', '/', $rel ); self::emit( $fh, $ifh, $abspath, $rel, (int) $file->getSize(), (int) $file->getMTime(), $max_bytes, $differential, $base_index, $excludes, $backup_dir, $seen, $count, $bytes, $total ); } } // Loose files directly under wp-content (drop-ins, index.php, custom includes). foreach ( $top_files as $tf ) { self::emit( $fh, $ifh, $tf[0], $tf[1], (int) @filesize( $tf[0] ), (int) @filemtime( $tf[0] ), $max_bytes, $differential, $base_index, $excludes, $backup_dir, $seen, $count, $bytes, $total ); } fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( $ifh ) { fclose( $ifh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } $deletions = array(); if ( $differential ) { foreach ( $base_index as $rel => $sig ) { if ( empty( $seen[ $rel ] ) ) { $deletions[] = $rel; } } } return array( 'count' => $count, 'bytes' => $bytes, 'deletions' => $deletions, 'total' => $total ); } /** * Process one file: honor excludes + max size, write it to the list (and index), * and in differential mode skip unchanged files while tracking what was seen. * * @param resource $fh List handle. * @param resource|null $ifh Index handle (or ''). * @param string $abspath Absolute path. * @param string $rel wp-content-relative path. * @param int $size File size. * @param int $mtime Modified time. * @param int $max_bytes Size cap (0 = none). * @param bool $differential Differential mode. * @param array $base_index Base index map (differential). * @param array $excludes User excludes. * @param string $backup_dir Our backups dir. * @param array $seen (by ref) seen set. * @param int $count (by ref) written count. * @param int $bytes (by ref) written bytes. * @param int $total (by ref) total scanned. * @return void */ private static function emit( $fh, $ifh, $abspath, $rel, $size, $mtime, $max_bytes, $differential, array $base_index, array $excludes, $backup_dir, array &$seen, &$count, &$bytes, &$total ) { if ( self::excluded( $rel, $abspath, $excludes, $backup_dir ) ) { return; } if ( $max_bytes > 0 && $size > $max_bytes ) { return; } ++$total; if ( $ifh ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming append to the file-index; WP_Filesystem cannot stream. fwrite( $ifh, $rel . "\t" . $size . "\t" . $mtime . "\n" ); } if ( $differential ) { $seen[ $rel ] = true; if ( isset( $base_index[ $rel ] ) && $base_index[ $rel ] === $size . ':' . $mtime ) { return; // Unchanged — skip. } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming append to the file-list; WP_Filesystem cannot stream. fwrite( $fh, $rel . "\n" ); ++$count; $bytes += $size; } /** * Whether a content-relative path should be skipped. * * @param string $rel Content-relative path. * @param string $abspath Absolute path. * @param array $excludes User exclude fragments. * @param string $backup_dir AI-SiteArk backups dir (never back up ourselves). * @return bool */ private static function excluded( $rel, $abspath, array $excludes, $backup_dir ) { if ( $backup_dir && 0 === strpos( str_replace( '\\', '/', $abspath ), str_replace( '\\', '/', $backup_dir ) ) ) { return true; } $default = array( '/node_modules/', '/.git/', '/.svn/' ); $needle = '/' . $rel . '/'; foreach ( $default as $seg ) { if ( false !== strpos( $needle, $seg ) ) { return true; } } if ( '.DS_Store' === basename( $rel ) ) { return true; } foreach ( $excludes as $ex ) { $ex = trim( (string) $ex ); if ( '' !== $ex && false !== strpos( $rel, $ex ) ) { return true; } } return false; } }