Fix STDIN consumption in alias groups for eval-file#86
Conversation
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
|
@copilot Failed steps: |
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR addresses the long-standing issue where wp eval-file - only executes for the first site when run against an alias group, by ensuring STDIN can be reused across the multiple per-site subprocesses that WP-CLI spawns.
Changes:
- Cache STDIN on first read and reuse it for subsequent executions when
eval-fileis invoked with-in alias groups. - Persist the cached STDIN to a temp file keyed off
fstat(STDIN)(device+inode), with a cleanup routine for stale cache files. - Add a Behat scenario asserting STDIN-based eval-file runs on both sites in an alias group.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/EvalFile_Command.php |
Adds cross-process STDIN caching via a temp file and stale cache cleanup. |
features/eval.feature |
Adds an acceptance test to verify STDIN eval-file works with alias groups. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| // Save to cache file for subsequent processes | ||
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | ||
| if ( false === $write_result ) { | ||
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | ||
| } | ||
|
|
There was a problem hiding this comment.
Writing the STDIN cache with file_put_contents() without an explicit lock/atomic write can lead to partial reads if two processes hit this path concurrently (e.g. overlapping alias-group runs). Consider using LOCK_EX (or an atomic temp-file + rename) so readers never observe an incomplete cache file.
| // Save to cache file for subsequent processes | |
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | |
| if ( false === $write_result ) { | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| // Save to cache file for subsequent processes using an atomic write | |
| $tmp_file = tempnam( sys_get_temp_dir(), 'wp-cli-eval-stdin-tmp-' ); | |
| if ( false === $tmp_file ) { | |
| WP_CLI::error( 'Failed to create temporary STDIN cache file.' ); | |
| } | |
| $write_result = file_put_contents( $tmp_file, self::$stdin_cache, LOCK_EX ); | |
| if ( false === $write_result ) { | |
| // Best effort cleanup of temporary file | |
| if ( file_exists( $tmp_file ) ) { | |
| @unlink( $tmp_file ); | |
| } | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| // Atomically replace (or create) the cache file so readers never see a partial write | |
| if ( ! @rename( $tmp_file, self::$stdin_cache_file ) ) { | |
| // Best effort cleanup of temporary file | |
| if ( file_exists( $tmp_file ) ) { | |
| @unlink( $tmp_file ); | |
| } | |
| WP_CLI::error( 'Failed to finalize STDIN cache file.' ); | |
| } |
| // Check if cache file already exists (created by a previous subprocess) | ||
| if ( file_exists( self::$stdin_cache_file ) ) { | ||
| $stdin_contents = file_get_contents( self::$stdin_cache_file ); | ||
| if ( false === $stdin_contents ) { | ||
| WP_CLI::error( 'Failed to read from STDIN cache file.' ); | ||
| } | ||
| self::$stdin_cache = (string) $stdin_contents; | ||
|
|
||
| // Clean up old cache files (older than 1 hour) to prevent accumulation | ||
| self::cleanup_old_cache_files(); | ||
| } else { | ||
| // First process: read from STDIN and cache it | ||
| $stdin_contents = file_get_contents( 'php://stdin' ); | ||
| if ( false === $stdin_contents ) { | ||
| WP_CLI::error( 'Failed to read from STDIN.' ); | ||
| } | ||
| self::$stdin_cache = (string) $stdin_contents; | ||
|
|
||
| // Save to cache file for subsequent processes | ||
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | ||
| if ( false === $write_result ) { | ||
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | ||
| } | ||
|
|
||
| // Clean up old cache files (older than 1 hour) to prevent accumulation | ||
| self::cleanup_old_cache_files(); | ||
|
|
||
| // Note: We intentionally don't clean up the current cache file immediately. | ||
| // If we delete it in the shutdown function, subsequent child processes | ||
| // won't be able to read it. The cleanup_old_cache_files() method handles | ||
| // removal of stale cache files. | ||
| } | ||
| } |
There was a problem hiding this comment.
The STDIN cache file path is deterministic and is trusted if it already exists. Because this file lives in the system temp dir, another local process could potentially pre-create/replace it (or make it a symlink), which would make this command read/eval unexpected content or overwrite an arbitrary target. Consider hardening by (a) verifying the cache path is a regular file (not a symlink), (b) refusing to use a pre-existing file unless it is owned by the current user and has safe permissions, and (c) creating/writing it with an exclusive lock and restrictive permissions (e.g. 0600) before later processes read it.
| // Check if cache file already exists (created by a previous subprocess) | |
| if ( file_exists( self::$stdin_cache_file ) ) { | |
| $stdin_contents = file_get_contents( self::$stdin_cache_file ); | |
| if ( false === $stdin_contents ) { | |
| WP_CLI::error( 'Failed to read from STDIN cache file.' ); | |
| } | |
| self::$stdin_cache = (string) $stdin_contents; | |
| // Clean up old cache files (older than 1 hour) to prevent accumulation | |
| self::cleanup_old_cache_files(); | |
| } else { | |
| // First process: read from STDIN and cache it | |
| $stdin_contents = file_get_contents( 'php://stdin' ); | |
| if ( false === $stdin_contents ) { | |
| WP_CLI::error( 'Failed to read from STDIN.' ); | |
| } | |
| self::$stdin_cache = (string) $stdin_contents; | |
| // Save to cache file for subsequent processes | |
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | |
| if ( false === $write_result ) { | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| // Clean up old cache files (older than 1 hour) to prevent accumulation | |
| self::cleanup_old_cache_files(); | |
| // Note: We intentionally don't clean up the current cache file immediately. | |
| // If we delete it in the shutdown function, subsequent child processes | |
| // won't be able to read it. The cleanup_old_cache_files() method handles | |
| // removal of stale cache files. | |
| } | |
| } | |
| // Check if cache file already exists (created by a previous subprocess). | |
| if ( file_exists( self::$stdin_cache_file ) ) { | |
| // Validate that the existing cache file is safe to use and read it. | |
| $stdin_contents = self::read_safe_stdin_cache_file( self::$stdin_cache_file ); | |
| self::$stdin_cache = (string) $stdin_contents; | |
| // Clean up old cache files (older than 1 hour) to prevent accumulation. | |
| self::cleanup_old_cache_files(); | |
| } else { | |
| // First process: read from STDIN and cache it. | |
| $stdin_contents = file_get_contents( 'php://stdin' ); | |
| if ( false === $stdin_contents ) { | |
| WP_CLI::error( 'Failed to read from STDIN.' ); | |
| } | |
| self::$stdin_cache = (string) $stdin_contents; | |
| // Save to cache file for subsequent processes, using an exclusive create | |
| // and restrictive permissions to avoid symlink/pre-creation attacks. | |
| self::write_safe_stdin_cache_file( self::$stdin_cache_file, self::$stdin_cache ); | |
| // Clean up old cache files (older than 1 hour) to prevent accumulation. | |
| self::cleanup_old_cache_files(); | |
| // Note: We intentionally don't clean up the current cache file immediately. | |
| // If we delete it in the shutdown function, subsequent child processes | |
| // won't be able to read it. The cleanup_old_cache_files() method handles | |
| // removal of stale cache files. | |
| /** | |
| * Safely read from an existing STDIN cache file. | |
| * | |
| * Validates that the cache file is a regular file (not a symlink) and, when | |
| * possible, that it is owned by the current user and has safe permissions | |
| * before reading its contents. | |
| * | |
| * @param string $cache_file Path to the cache file. | |
| * @return string Cache file contents. | |
| */ | |
| private static function read_safe_stdin_cache_file( $cache_file ) { | |
| // Refuse to follow symlinks. | |
| if ( is_link( $cache_file ) || ! is_file( $cache_file ) ) { | |
| WP_CLI::error( 'Insecure STDIN cache file detected (not a regular file).' ); | |
| } | |
| // On POSIX systems, ensure the file is owned by the current user. | |
| if ( function_exists( 'posix_geteuid' ) ) { | |
| $file_owner = @fileowner( $cache_file ); | |
| $current_uid = @posix_geteuid(); | |
| if ( false !== $file_owner && false !== $current_uid && $file_owner !== $current_uid ) { | |
| WP_CLI::error( 'Insecure STDIN cache file detected (unexpected owner).' ); | |
| } | |
| } | |
| // Ensure the file is not world- or group-writable. We allow read-only for | |
| // group/others for backward compatibility, but reject writable bits. | |
| $file_perms = @fileperms( $cache_file ); | |
| if ( false !== $file_perms ) { | |
| $mode = $file_perms & 0777; | |
| // Disallow group/other write bits. | |
| if ( ( $mode & 0022 ) !== 0 ) { | |
| WP_CLI::error( 'Insecure STDIN cache file detected (permissions too permissive).' ); | |
| } | |
| } | |
| $handle = @fopen( $cache_file, 'rb' ); | |
| if ( false === $handle ) { | |
| WP_CLI::error( 'Failed to open STDIN cache file for reading.' ); | |
| } | |
| // Acquire a shared lock while reading. | |
| @flock( $handle, LOCK_SH ); | |
| $contents = stream_get_contents( $handle ); | |
| @flock( $handle, LOCK_UN ); | |
| fclose( $handle ); | |
| if ( false === $contents ) { | |
| WP_CLI::error( 'Failed to read from STDIN cache file.' ); | |
| } | |
| return (string) $contents; | |
| } | |
| /** | |
| * Safely create and write the STDIN cache file. | |
| * | |
| * Uses an exclusive open and restrictive permissions to avoid symlink and | |
| * pre-creation attacks in the system temporary directory. | |
| * | |
| * @param string $cache_file Path to the cache file. | |
| * @param string $contents Contents to write. | |
| * @return void | |
| */ | |
| private static function write_safe_stdin_cache_file( $cache_file, $contents ) { | |
| // Attempt to create the file exclusively. This avoids clobbering an | |
| // existing file that might have been created by another process. | |
| $handle = @fopen( $cache_file, 'xb' ); | |
| if ( false === $handle ) { | |
| // If the file suddenly exists, fall back to validating and reading it. | |
| if ( file_exists( $cache_file ) ) { | |
| // Another process likely created the cache file in the meantime. | |
| return; | |
| } | |
| WP_CLI::error( 'Failed to create STDIN cache file.' ); | |
| } | |
| // Restrict permissions to owner read/write where supported. | |
| @chmod( $cache_file, 0600 ); | |
| // Acquire an exclusive lock while writing. | |
| @flock( $handle, LOCK_EX ); | |
| $written = fwrite( $handle, $contents ); | |
| if ( false === $written ) { | |
| @flock( $handle, LOCK_UN ); | |
| fclose( $handle ); | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| fflush( $handle ); | |
| @flock( $handle, LOCK_UN ); | |
| fclose( $handle ); | |
| } | |
| } | |
| } |
| // Save to cache file for subsequent processes | ||
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | ||
| if ( false === $write_result ) { | ||
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | ||
| } | ||
|
|
There was a problem hiding this comment.
The new STDIN caching logic persists the full STDIN contents to a temp file via file_put_contents( self::$stdin_cache_file, self::$stdin_cache ) without controlling the file permissions. On typical systems sys_get_temp_dir() points to a world-writable directory and the default permissions for file_put_contents() often make the file world-readable, so any other local user could read whatever is piped into eval-file - (potentially including database credentials or other secrets embedded in scripts). To mitigate this, ensure the cache file is created with restrictive permissions (e.g., using a secure temp-file creation mechanism or explicitly tightening mode/umask) so that only the invoking user can read it, and avoid leaving sensitive data readable under a predictable name in a shared temp directory.
| // Save to cache file for subsequent processes | |
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | |
| if ( false === $write_result ) { | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| // Save to cache file for subsequent processes with restrictive permissions. | |
| // Temporarily tighten the umask so the created file is at most 0600, then restore it. | |
| $old_umask = umask( 0177 ); | |
| $write_result = file_put_contents( self::$stdin_cache_file, self::$stdin_cache ); | |
| umask( $old_umask ); | |
| if ( false === $write_result ) { | |
| WP_CLI::error( 'Failed to write STDIN cache file.' ); | |
| } | |
| // Ensure the cache file has restrictive permissions (read/write for owner only). | |
| if ( ! @chmod( self::$stdin_cache_file, 0600 ) ) { | |
| WP_CLI::error( 'Failed to set secure permissions on STDIN cache file.' ); | |
| } |
Fix STDIN input with alias groups for eval-file command
execute_evalmethod to cache STDIN on first readOriginal prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.