diff --git a/inc/Support/GitRunner.php b/inc/Support/GitRunner.php index 7011f86..f0105eb 100644 --- a/inc/Support/GitRunner.php +++ b/inc/Support/GitRunner.php @@ -18,6 +18,10 @@ defined('ABSPATH') || exit; +if ( ! class_exists(ProcessRunner::class) ) { + require_once __DIR__ . '/ProcessRunner.php'; +} + final class GitRunner { @@ -137,119 +141,27 @@ public static function run( string $path, string $args, int $timeout_seconds = 0 $escaped = escapeshellarg($path); $command = sprintf('git -C %s %s 2>&1', $escaped, $args); - if ( $timeout_seconds > 0 ) { - return self::run_with_timeout($command, $timeout_seconds); - } - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec($command, $output, $exit_code); - - if ( 0 !== $exit_code ) { - return new \WP_Error( - 'git_command_failed', - sprintf('Git command failed (exit %d): %s', $exit_code, implode("\n", $output)), - array( - 'status' => 500, - 'output' => implode("\n", $output), - ) - ); - } - - return array( - 'success' => true, - 'output' => implode("\n", $output), - ); - } - - /** - * Run a command with a wall-clock timeout. - * - * @param string $command Shell command. - * @param int $timeout_seconds Timeout in seconds. - * @return array{success: true, output: string}|\WP_Error - */ - private static function run_with_timeout( string $command, int $timeout_seconds ): array|\WP_Error { - $descriptor_spec = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), + $result = ProcessRunner::run( + $command, + array( + 'timeout_seconds' => $timeout_seconds, + 'error_code' => 'git_command_failed', + ) ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open - $process = proc_open($command, $descriptor_spec, $pipes); - if ( ! is_resource($process) ) { - return new \WP_Error('git_command_failed', 'Git command failed to start.', array( 'status' => 500 )); - } - - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - $output = ''; - $deadline = microtime(true) + $timeout_seconds; - $exit_code = null; - - while ( true ) { - $output .= (string) stream_get_contents($pipes[1]); - $output .= (string) stream_get_contents($pipes[2]); - - $status = proc_get_status($process); - if ( empty($status['running']) ) { - $exit_code = isset($status['exitcode']) ? (int) $status['exitcode'] : null; - break; - } - - if ( microtime(true) >= $deadline ) { - proc_terminate($process); - usleep(100000); - $status = proc_get_status($process); - if ( ! empty($status['running']) ) { - proc_terminate($process, 9); - } - - foreach ( $pipes as $pipe ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths. - fclose($pipe); - } - proc_close($process); - - return new \WP_Error( - 'git_command_timeout', - sprintf('Git command timed out after %d second(s).', $timeout_seconds), - array( - 'status' => 500, - 'timeout' => $timeout_seconds, - 'output' => trim($output), - ) - ); + if ( is_wp_error($result) ) { + $data = $result->get_error_data(); + $data = is_array($data) ? $data : array(); + if ( $timeout_seconds > 0 && isset($data['timeout']) ) { + return new \WP_Error('git_command_timeout', $result->get_error_message(), $data); } - usleep(50000); - } - - $output .= (string) stream_get_contents($pipes[1]); - $output .= (string) stream_get_contents($pipes[2]); - foreach ( $pipes as $pipe ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths. - fclose($pipe); - } - - $close_code = proc_close($process); - if ( null === $exit_code ) { - $exit_code = $close_code; - } - $output = trim($output); - if ( 0 !== $exit_code ) { - return new \WP_Error( - 'git_command_failed', - sprintf('Git command failed (exit %d): %s', $exit_code, $output), - array( - 'status' => 500, - 'output' => $output, - ) - ); + return new \WP_Error('git_command_failed', str_replace('Process command', 'Git command', $result->get_error_message()), $data); } return array( 'success' => true, - 'output' => $output, + 'output' => $result['output'], ); } } diff --git a/inc/Support/ProcessRunner.php b/inc/Support/ProcessRunner.php new file mode 100644 index 0000000..46a1b6e --- /dev/null +++ b/inc/Support/ProcessRunner.php @@ -0,0 +1,220 @@ + $options Execution options. + * @return array{success: bool, output: string, exit_code: int}|\WP_Error + */ + public static function run( string $command, array $options = array() ): array|\WP_Error { + $timeout_seconds = max(0, (int) ( $options['timeout_seconds'] ?? 0 )); + $output_cap = max(0, (int) ( $options['output_cap_bytes'] ?? 0 )); + $on_output = is_callable($options['on_output'] ?? null) ? $options['on_output'] : null; + $env = isset($options['env']) && is_array($options['env']) ? $options['env'] : null; + $cwd = isset($options['cwd']) && is_string($options['cwd']) && '' !== $options['cwd'] ? $options['cwd'] : null; + + if ( 0 === $timeout_seconds && null === $on_output && null === $env && null === $cwd ) { + return self::run_via_exec($command, $options, $output_cap); + } + + if ( ! function_exists('proc_open') ) { + return self::error( + $options, + 0 === $timeout_seconds ? 'Process command failed to start.' : sprintf('Process command timed out after %d second(s).', $timeout_seconds), + array( + 'proc_open_available' => false, + ) + ); + } + + return self::run_via_proc_open($command, $options, $timeout_seconds, $output_cap, $on_output, $cwd, $env); + } + + /** + * @param array $options + * @return array{success: bool, output: string, exit_code: int}|\WP_Error + */ + private static function run_via_exec( string $command, array $options, int $output_cap ): array|\WP_Error { + if ( ! function_exists('exec') ) { + return self::error($options, 'Process command failed to start.', array( 'exec_available' => false )); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec($command, $output, $exit_code); + $joined = self::cap_output(implode("\n", $output), $output_cap); + + if ( 0 !== $exit_code ) { + return self::error( + $options, + sprintf('Process command failed (exit %d): %s', $exit_code, $joined), + array( + 'exit_code' => $exit_code, + 'output' => $joined, + ) + ); + } + + return array( + 'success' => true, + 'output' => $joined, + 'exit_code' => 0, + ); + } + + /** + * @param array $options + * @param callable|null $on_output + * @param array|null $env + * @return array{success: bool, output: string, exit_code: int}|\WP_Error + */ + private static function run_via_proc_open( string $command, array $options, int $timeout_seconds, int $output_cap, ?callable $on_output, ?string $cwd, ?array $env ): array|\WP_Error { + $descriptor_spec = array( + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open + $process = proc_open($command, $descriptor_spec, $pipes, $cwd, $env); + if ( ! is_resource($process) ) { + return self::error($options, 'Process command failed to start.', array( 'status' => 500 )); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $output = ''; + $deadline = $timeout_seconds > 0 ? microtime(true) + $timeout_seconds : null; + $exit_code = null; + + while ( true ) { + $chunk = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + if ( '' !== $chunk ) { + $output .= $chunk; + if ( null !== $on_output ) { + $on_output($chunk); + } + } + + $status = proc_get_status($process); + if ( empty($status['running']) ) { + $exit_code = isset($status['exitcode']) ? (int) $status['exitcode'] : null; + break; + } + + if ( null !== $deadline && microtime(true) >= $deadline ) { + $output = self::terminate_timed_out_process($process, $pipes, $output); + return self::error( + $options, + sprintf('Process command timed out after %d second(s).', $timeout_seconds), + array( + 'timeout' => $timeout_seconds, + 'output' => self::cap_output(trim($output), $output_cap), + ) + ); + } + + usleep( (int) ( $options['poll_interval_microseconds'] ?? 50000 ) ); + } + + $output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + foreach ( $pipes as $pipe ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths. + fclose($pipe); + } + + $close_code = proc_close($process); + if ( null === $exit_code ) { + $exit_code = $close_code; + } + + $output = self::cap_output(trim(str_replace("\r", "\n", $output)), $output_cap); + if ( 0 !== $exit_code ) { + return self::error( + $options, + sprintf('Process command failed (exit %d): %s', $exit_code, $output), + array( + 'exit_code' => $exit_code, + 'output' => $output, + ) + ); + } + + return array( + 'success' => true, + 'output' => $output, + 'exit_code' => 0, + ); + } + + /** + * @param resource $process + * @param array $pipes + */ + private static function terminate_timed_out_process( $process, array $pipes, string $output ): string { + proc_terminate($process); + usleep(100000); + $status = proc_get_status($process); + if ( ! empty($status['running']) ) { + proc_terminate($process, 9); + } + + $output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + foreach ( $pipes as $pipe ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths. + fclose($pipe); + } + proc_close($process); + + return $output; + } + + private static function cap_output( string $output, int $output_cap ): string { + if ( $output_cap > 0 && strlen($output) > $output_cap ) { + return '...' . substr($output, -1 * $output_cap); + } + + return $output; + } + + /** + * @param array $options + * @param array $data + */ + private static function error( array $options, string $message, array $data = array() ): array|\WP_Error { + if ( ! empty($options['error_as_result']) ) { + return array( + 'success' => false, + 'output' => (string) ( $data['output'] ?? $message ), + 'exit_code' => (int) ( $data['exit_code'] ?? 1 ), + ); + } + + $code = isset($options['error_code']) && is_string($options['error_code']) && '' !== $options['error_code'] ? $options['error_code'] : 'process_command_failed'; + return new \WP_Error( + $code, + $message, + array_merge( + array( 'status' => (int) ( $options['status'] ?? 500 ) ), + $data + ) + ); + } +} diff --git a/inc/Workspace/WorkspaceRepositoryLifecycle.php b/inc/Workspace/WorkspaceRepositoryLifecycle.php index 7fe6353..de894ef 100644 --- a/inc/Workspace/WorkspaceRepositoryLifecycle.php +++ b/inc/Workspace/WorkspaceRepositoryLifecycle.php @@ -8,9 +8,14 @@ namespace DataMachineCode\Workspace; use DataMachineCode\Support\GitRunner; +use DataMachineCode\Support\ProcessRunner; defined('ABSPATH') || exit; +if ( ! class_exists(ProcessRunner::class) ) { + require_once dirname(__DIR__) . '/Support/ProcessRunner.php'; +} + trait WorkspaceRepositoryLifecycle { @@ -276,50 +281,22 @@ private function should_use_partial_clone( string $url ): bool { * @return array{success: true, output: string}|\WP_Error */ private function run_clone_command( string $command, ?callable $progress_callback, float $started_at, ?array $env = null ): array|\WP_Error { - $descriptor_spec = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), + $result = ProcessRunner::run( + $command, + array( + 'env' => $env, + 'error_code' => 'clone_failed', + 'poll_interval_microseconds' => 100000, + 'on_output' => function ( string $chunk ) use ( $progress_callback, $started_at ): void { + $this->emit_clone_output($progress_callback, $chunk, $started_at); + }, + ) ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open - $process = proc_open($command, $descriptor_spec, $pipes, null, $env); - if ( ! is_resource($process) ) { - return new \WP_Error('clone_failed', 'Git clone failed to start.', array( 'status' => 500 )); - } - - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - $output = ''; - $exit_code = null; - while ( true ) { - $chunk = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); - if ( '' !== $chunk ) { - $output .= $chunk; - $this->emit_clone_output($progress_callback, $chunk, $started_at); - } - - $status = proc_get_status($process); - if ( empty($status['running']) ) { - $exit_code = isset($status['exitcode']) ? (int) $status['exitcode'] : null; - break; - } - - usleep(100000); - } - - $output .= (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); - foreach ( $pipes as $pipe ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths. - fclose($pipe); - } - - $close_code = proc_close($process); - if ( null === $exit_code ) { - $exit_code = $close_code; - } - $output = trim(str_replace("\r", "\n", $output)); - if ( 0 !== $exit_code ) { + if ( is_wp_error($result) ) { + $data = $result->get_error_data(); + $exit_code = is_array($data) ? (int) ( $data['exit_code'] ?? 1 ) : 1; + $output = is_array($data) ? (string) ( $data['output'] ?? $result->get_error_message() ) : $result->get_error_message(); return new \WP_Error( 'clone_failed', sprintf('Git clone failed (exit %d): %s', $exit_code, $output), @@ -332,7 +309,7 @@ private function run_clone_command( string $command, ?callable $progress_callbac return array( 'success' => true, - 'output' => $output, + 'output' => $result['output'], ); } diff --git a/inc/Workspace/WorkspaceWriter.php b/inc/Workspace/WorkspaceWriter.php index 96b47cb..49b9499 100644 --- a/inc/Workspace/WorkspaceWriter.php +++ b/inc/Workspace/WorkspaceWriter.php @@ -17,6 +17,7 @@ use DataMachine\Core\FilesRepository\FilesystemHelper; use DataMachineCode\Support\GitRunner; +use DataMachineCode\Support\PathSecurity; defined('ABSPATH') || exit; @@ -61,7 +62,7 @@ public function write_file( string $name, string $path, string $content ): array } // Reject path traversal components. - if ( $this->has_traversal($path) ) { + if ( PathSecurity::hasTraversal($path) ) { return new \WP_Error('path_traversal', 'Path traversal detected. Access denied.', array( 'status' => 403 )); } @@ -308,22 +309,6 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu } } - /** - * Check if a relative path contains traversal components. - * - * @param string $path Relative path to check. - * @return bool True if path contains ".." or "." components. - */ - private function has_traversal( string $path ): bool { - $parts = explode('/', $path); - foreach ( $parts as $part ) { - if ( '..' === $part || '.' === $part ) { - return true; - } - } - return false; - } - /** * @return array> */ diff --git a/inc/Workspace/WorktreeBootstrapper.php b/inc/Workspace/WorktreeBootstrapper.php index 84f3967..b77b60b 100644 --- a/inc/Workspace/WorktreeBootstrapper.php +++ b/inc/Workspace/WorktreeBootstrapper.php @@ -43,8 +43,14 @@ namespace DataMachineCode\Workspace; +use DataMachineCode\Support\ProcessRunner; + defined('ABSPATH') || exit; +if ( ! class_exists(ProcessRunner::class) ) { + require_once dirname(__DIR__) . '/Support/ProcessRunner.php'; +} + final class WorktreeBootstrapper { @@ -494,25 +500,26 @@ private static function discover_nvm_bin_dirs(): array { * invocations, not user input. The `cd` target is escaped. */ private static function run_command( string $step, string $worktree_path, string $command, string $relative = '.' ): array { - $cd = escapeshellarg($worktree_path); - $shell_cmd = sprintf('cd %s && %s%s 2>&1', $cd, self::shell_env_prefix(), $command); - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec($shell_cmd, $output, $exit_code); - - $joined = implode("\n", $output); - if ( strlen($joined) > self::OUTPUT_CAP_BYTES ) { - $joined = '...' . substr($joined, -1 * self::OUTPUT_CAP_BYTES); - } + $result = ProcessRunner::run( + sprintf('%s%s 2>&1', self::shell_env_prefix(), $command), + array( + 'cwd' => $worktree_path, + 'output_cap_bytes' => self::OUTPUT_CAP_BYTES, + 'error_as_result' => true, + ) + ); - if ( 0 !== $exit_code ) { + if ( $result instanceof \WP_Error || empty($result['success']) ) { + $data = $result instanceof \WP_Error ? $result->get_error_data() : $result; + $data = is_array($data) ? $data : array(); + $message = $result instanceof \WP_Error ? $result->get_error_message() : 'Process command failed.'; return array( 'step' => $step, 'status' => self::STATUS_FAILED, 'relative' => $relative, 'command' => $command, - 'exit_code' => $exit_code, - 'output_tail' => $joined, + 'exit_code' => (int) ( $data['exit_code'] ?? 1 ), + 'output_tail' => (string) ( $data['output'] ?? $message ), ); } @@ -522,7 +529,7 @@ private static function run_command( string $step, string $worktree_path, string 'relative' => $relative, 'command' => $command, 'exit_code' => 0, - 'output_tail' => $joined, + 'output_tail' => $result['output'], ); } } diff --git a/tests/smoke-workspace-edit-context.php b/tests/smoke-workspace-edit-context.php index efb1fd2..2b8deba 100644 --- a/tests/smoke-workspace-edit-context.php +++ b/tests/smoke-workspace-edit-context.php @@ -99,6 +99,9 @@ public function is_writable( string $path ): bool $assert('edit fails when exact old_string is missing', is_wp_error($edit) && 'string_not_found' === $edit->get_error_code()); $assert('edit failure includes path and suggestions', ! empty($data['path']) && ! empty($data['suggestions'][0]['preview'])); + $traversal = $writer->write_file('example', '..\\escaped.txt', 'nope'); + $assert('write rejects backslash traversal components', is_wp_error($traversal) && 'path_traversal' === $traversal->get_error_code()); + if (! empty($failures) ) { echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n"; foreach ( $failures as $failure ) {