logger = $logger; } /** * Initialize file functionality. * * @return void */ public function init(): void { } /** * Write content to a file. * * @param string $path File path * @param string $content Content to write * @return bool */ public function write(string $path, string $content): bool { $ok = @file_put_contents($path, $content) !== false; if ($ok) { // Keep cache files readable by the web server without making them writable. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Runtime cache files use local permissions and WP_Filesystem is not available on every request. @chmod($path, 0644); } return $ok; } /** * Read content from a file. * * @param string $path File path * @return string|false */ public function read(string $path): string|false { return @file_get_contents($path); } /** * Read a file through the WordPress Filesystem API. * * Returns false when the API is unavailable or fails to initialize. * * @param string $path File path * @return string|false */ public function wpread(string $path): string|false { $fs = $this->get_filesystem(); return $fs ? $fs->get_contents($path) : false; } /** * Get the cached WordPress filesystem instance. * * @return \WP_Filesystem_Base|null */ private function get_filesystem(): ?\WP_Filesystem_Base { if (self::$wp_fs instanceof \WP_Filesystem_Base) { return self::$wp_fs; } if (!defined('ABSPATH')) { return null; } if (!function_exists('WP_Filesystem')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } global $wp_filesystem; if (!WP_Filesystem()) { return null; } if ($wp_filesystem instanceof \WP_Filesystem_Base) { self::$wp_fs = $wp_filesystem; return self::$wp_fs; } return null; } /** * Delete a file or directory. * * @param string $path File or directory path * @param bool $recursive Recursively delete directory contents * @return bool */ public function delete(string $path, bool $recursive = false): bool { if (!@file_exists($path) && !@is_link($path)) { return false; } if (@is_dir($path) && !@is_link($path)) { if (!$recursive) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Runtime cache directories are local and WP_Filesystem may require credentials. return @rmdir($path); } return $this->delete_dir_recursive($path); } if (@is_link($path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Unlink symlinks directly so recursive cleanup never follows them. return @unlink($path); } if (function_exists('wp_delete_file')) { wp_delete_file($path); return !@file_exists($path); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Fallback for early/runtime contexts where the WordPress wrapper is unavailable. return @unlink($path); } /** * Recursively delete a directory and all contents. * * Symlinks are unlinked, not followed, to avoid infinite recursion. * * @param string $dir Directory path * @return bool */ private function delete_dir_recursive(string $dir): bool { $entries = @scandir($dir); if ($entries === false) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Runtime cache directories are local and WP_Filesystem may require credentials. return @rmdir($dir); } $success = true; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $full = $dir . DIRECTORY_SEPARATOR . $entry; if (@is_link($full)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Unlink symlinks directly so recursive cleanup never follows them. if (!@unlink($full)) { $success = false; } } elseif (@is_dir($full)) { if (!$this->delete_dir_recursive($full)) { $success = false; } } else { if (function_exists('wp_delete_file')) { wp_delete_file($full); if (@file_exists($full)) { $success = false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Fallback for early/runtime contexts where the WordPress wrapper is unavailable. } elseif (!@unlink($full)) { $success = false; } } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Runtime cache directories are local and WP_Filesystem may require credentials. return @rmdir($dir) && $success; } /** * Atomically write content to a file. * * Writes to a unique temporary file in the same directory and then renames * it into place. On POSIX filesystems rename() is atomic, so readers never * see a partially-written file. * * @param string $path Destination path. * @param string $content Content to write. * @return bool True on success, false on failure. */ public function atomic_write(string $path, string $content): bool { $dir = dirname($path); // Create the temp file in the same directory so rename() is atomic. $tmp_path = @tempnam($dir, 'rctmp'); if ($tmp_path === false) { return false; } if (@file_put_contents($tmp_path, $content) === false) { $this->delete($tmp_path); return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Atomic cache files use local permissions and WP_Filesystem is not available on every request. @chmod($tmp_path, 0644); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Same-directory rename is required for atomic cache replacement. if (!@rename($tmp_path, $path)) { $this->delete($tmp_path); return false; } return true; } /** * Update a PHP `return array(...)` config file under a best-effort lock. * * One writer holds an advisory lock across the read-merge-write cycle. * Lock failures degrade to last-writer-wins. They never fail the write. * * @param string $path Path to a PHP file holding one `return array(...)`. * @param callable $mutate Receives the current array, returns the new array. * @return bool True when the write succeeded, lock held or not. * @throws \Throwable Re-throws mutator exceptions after lock release. */ public function locked_update(string $path, callable $mutate): bool { $handle = $this->acquire_lock($this->lock_path_for($path)); try { // The read happens inside the lock so a concurrent writer's // change cannot be lost between read and write. $updated = $mutate($this->read_php_array($path)); if (!is_array($updated)) { return false; } if (!$this->atomic_write($path, $this->render_php_config($updated))) { return false; } $this->invalidate_opcache($path); return true; } finally { $this->release_lock($handle); } } /** * Build the lock file path for a config file. * * This is the only place that builds the lock path. Callers such as * uninstall() call it. Lock and data file share one filesystem. * * @param string $path Data file path. * @return string Lock file path. */ public function lock_path_for(string $path): string { return dirname($path) . '/.' . basename($path) . '.lock'; } /** * Try to acquire an exclusive non-blocking lock with a bounded retry. * * The retry is bounded and non-blocking. PHP has no timeout for a * blocking flock(), so a hung holder would hang the request forever. * * @param string $lock_path Lock file path. * @return resource|null Lock handle, or null when locking is unavailable. */ private function acquire_lock(string $lock_path) { if (!function_exists('flock')) { $this->logger?->info('File lock unavailable: flock() is disabled; continuing unlocked', [ 'lock' => $lock_path, ]); return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Lock files use local fopen; WP_Filesystem cannot hand out lock handles. $handle = @fopen($lock_path, 'c'); if ($handle === false) { $this->logger?->warning('File lock could not be created; continuing unlocked', [ 'lock' => $lock_path, ]); return null; } for ($attempt = 0; $attempt < self::LOCK_ATTEMPTS; $attempt++) { if (@flock($handle, LOCK_EX | LOCK_NB)) { // Widen the mode after the lock. The FPM user and the CLI // user can differ, and the file holds no data. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Lock files use local permissions. @chmod($lock_path, 0666); return $handle; } usleep(self::LOCK_RETRY_MICROSECONDS); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Local lock file handle. @fclose($handle); $this->logger?->warning('File lock stayed busy; continuing unlocked', [ 'lock' => $lock_path, ]); return null; } /** * Release the lock. Safe to call with a null handle. * * @param resource|null $handle Lock handle from acquire_lock(). * @return void */ private function release_lock($handle): void { if (!is_resource($handle)) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock -- Local lock file handle. @flock($handle, LOCK_UN); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Local lock file handle. @fclose($handle); } /** * Read a PHP file that contains one `return array(...)`. * * A missing, unreadable, or corrupt file starts from an empty array so a * later write self-heals the file. Corrupt files log a warning. * * @param string $path Config file path. * @return array */ private function read_php_array(string $path): array { if (!@is_readable($path)) { return []; } $loaded = null; $parse_error = null; $ob_level = ob_get_level(); ob_start(); try { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_include -- The PHP config file must execute to return its array. $loaded = @include $path; } catch (\Throwable $exception) { $parse_error = $exception; } finally { // Discard stray output a corrupted file may emit on include. // Close only our own buffer, never an outer one. if (ob_get_level() > $ob_level) { ob_end_clean(); } } if ($parse_error !== null) { $this->logger?->warning('PHP config file could not be parsed; starting from an empty array', [ 'path' => $path, 'error' => $parse_error->getMessage(), ]); return []; } if (!is_array($loaded)) { $this->logger?->warning('PHP config file did not return an array; starting from an empty array', [ 'path' => $path, ]); return []; } return $loaded; } /** * Render a PHP config file body: open tag, ABSPATH guard, one return. * * This is the only place that composes the file text. var_export * serializes data only, so plugin-written content can never contain code. * * @param array $data Config data to store. * @return string File content. */ private function render_php_config(array $data): string { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- var_export builds the config file literal; it serializes data only. $exported = var_export($data, true); return "is_writable($parent_dir); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- This checks local runtime cache permissions without initializing WP_Filesystem. return @is_writable($path); } /** * Check if a file exists. * * @param string $path File path * @return bool */ public function exists(string $path): bool { return @file_exists($path); } /** * Create a directory. * * @param string $path Directory path * @return bool */ public function mkdir(string $path): bool { if (is_dir($path)) { return true; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Recursive local cache creation must work during runtime without filesystem credentials. return @mkdir($path, 0755, true); } /** * Get the RunCache config directory inside the uploads folder. * * @return string|false Directory path with trailing slash, or false when the uploads directory cannot be resolved. */ public function get_config_dir(): string|false { if (!function_exists('wp_upload_dir')) { return false; } // Never create date-based subdirectories; we only need basedir. $uploads = wp_upload_dir(null, false); if (!is_array($uploads) || !empty($uploads['error']) || empty($uploads['basedir'])) { return false; } return trailingslashit(wp_normalize_path($uploads['basedir'])) . 'runcache/'; } /** * Ensure the RunCache config directory exists with protection files. * * Creates uploads/runcache/ when missing and writes an index.html * sentinel plus an .htaccess deny file so generated configuration * files are not exposed directly on Apache or LiteSpeed servers. * * @return string|false Directory path with trailing slash, or false on failure. */ public function ensure_config_dir(): string|false { $dir = $this->get_config_dir(); if ($dir === false) { return false; } if (!$this->mkdir($dir)) { return false; } $index_file = $dir . 'index.html'; if (!$this->exists($index_file)) { $this->write($index_file, ''); } $htaccess_file = $dir . '.htaccess'; if (!$this->exists($htaccess_file)) { $this->write( $htaccess_file, "# RunCache: deny direct access to generated configuration files\n" . "\n" . " Require all denied\n" . "\n" . "\n" . " Order deny,allow\n" . " Deny from all\n" . "\n" ); } return $dir; } /** * Get file size. * * @param string $path File path * @return int */ public function size(string $path): int { $size = @filesize($path); return $size !== false ? (int) $size : 0; } /** * List files in directory. * * @param string $path Directory path * @param bool $recursive Recursively list subdirectories * @param bool $include_hidden Include hidden files * @return array|false */ public function dirlist(string $path, bool $recursive = false, bool $include_hidden = true): array|false { if (!is_dir($path)) { return false; } $entries = @scandir($path); if ($entries === false) { return false; } $result = []; foreach ($entries as $name) { if ($name === '.' || $name === '..') { continue; } if (!$include_hidden && str_starts_with($name, '.')) { continue; } $full = $path . DIRECTORY_SEPARATOR . $name; $is_dir = @is_dir($full) && !@is_link($full); $entry = [ 'name' => $name, 'type' => $is_dir ? 'd' : 'f', 'size' => (int) ($is_dir ? 0 : @filesize($full)), 'lastmodunix' => (int) @filemtime($full), ]; if ($recursive && $is_dir) { $sub = $this->dirlist($full, true, $include_hidden); $entry['files'] = is_array($sub) ? $sub : []; } $result[$name] = $entry; } return $result; } /** * Check if we have both read and write permissions for a file. * * Tests permissions by attempting to actually read the file. * * @param string $path File path to check * @return array Returns array with 'readable', 'writable', and 'success' keys * readable: bool - if file can be read * writable: bool - if file can be written * success: bool - if both checks pass */ public function check_permissions(string $path): array { $result = [ 'readable' => false, 'writable' => false, 'success' => false, ]; if (!$this->exists($path)) { return $result; } $content = $this->read($path); $result['readable'] = $content !== false; $result['writable'] = $this->is_writable($path); $result['success'] = $result['readable'] && $result['writable']; return $result; } /** * Locate wp-config.php — checks both ABSPATH and one directory above. * * @return string|false The normalized path to wp-config.php, or false if not found */ public function locate_wp_config(): string|false { $primary = wp_normalize_path(ABSPATH . 'wp-config.php'); if ($this->exists($primary)) { return $primary; } $alt = wp_normalize_path(dirname(ABSPATH) . '/wp-config.php'); if ($this->exists($alt) && !$this->exists(dirname(ABSPATH) . '/wp-settings.php')) { return $alt; } return false; } }